appmint_flutter_authentication_demo
Get the code
Browse it on GitHub · Download the repository as a zip
git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/appmint_flutter_authentication_demoThis tutorial builds the authentication example one file at a time. By the end you will have an app that connects to any organization you point it at, signs in both kinds of people, survives a verification challenge, remembers accounts, and shows you every request it makes with the tokens that rode on it.
Read this one first whatever you are building. Every other Flutter example starts where it ends.
Build a Flutter app builds a minimal version of the same thing in one file. This tutorial builds the real example — six files, the request log, and the paths that only matter once people are actually using your app.
Before you start
Flutter 3.27+, and an organization with an app credential from Studio Manager. You do not need to have decided anything else.
Step 1 — The project
flutter create --platforms=android,ios,web --org io.appmint \
--project-name appmint_flutter_authentication_demo \
appmint_flutter_authentication_demo
cd appmint_flutter_authentication_demoAdd the client to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
appmint_flutter_client:
git:
url: https://github.com/JacLight/appmint-client.git
path: appmint_flutter_clientflutter pub getStep 2 — Hold the state
The app needs exactly three things: a client, the person signed into it, and a log of what it did. No state-management package — the client does not ask for one, and an example should not smuggle that choice into your project.
lib/main.dart:
class DemoState extends ChangeNotifier {
Appmint? _appmint;
AppmintUser? _user;
StreamSubscription<AppmintUser?>? _sub;
final log = CallLog();
Appmint? get appmint => _appmint;
AppmintUser? get user => _user;
bool get connected => _appmint != null;
Future<void> connect(AppmintConfig config) async {
await _sub?.cancel();
_appmint?.dispose();
final appmint = Appmint(config);
appmint.http.onCall = log.add; // ← every request, reported
_appmint = appmint;
_user = await appmint.auth.restore(); // a stored session, if there is one
_sub = appmint.auth.changes.listen((u) {
_user = u;
notifyListeners();
});
notifyListeners();
}
}Two lines there matter more than the rest. onCall is what makes this a
teaching app rather than just an app. auth.changes means you never manually
route after signing in — the stream fires and the tree rebuilds.
Then route on what you have:
final Widget body;
if (!state.connected) {
body = ConnectScreen(state: state);
} else if (state.user == null) {
body = SignInScreen(state: state);
} else {
body = SessionScreen(state: state);
}Step 3 — The request log
This is the part worth building first, because it explains everything that
follows. lib/call_log.dart:
class CallLog extends ChangeNotifier {
final List<AppmintCall> _calls = [];
List<AppmintCall> get calls => List.unmodifiable(_calls);
void add(AppmintCall call) {
_calls.insert(0, call);
if (_calls.length > 80) _calls.removeLast();
notifyListeners();
}
}Each row renders the status, the method and path, and two badges:
_Chip(label: 'app token', on: call.appAuthenticated),
_Chip(label: 'user token', on: call.sentUserToken),That is the whole idea. AppEngine puts two tokens on a request and they answer
different questions — Authorization: Bearer … is the app's ("may this
application talk to AppEngine at all") and x-client-authorization is the
person's ("who is doing this"). The header called Authorization is not the
user's, which surprises everybody. Watching the second badge stay dim through
sign-in and then light up on the next call teaches that in about four seconds.
AppmintCall never carries token values — only whether each was attached.
Step 4 — Connect to an organization
lib/screens/connect_screen.dart asks for five values: the AppEngine URL, the
org id, and the app id, key and secret.
final config = AppmintConfig(
baseUrl: _baseUrl.text.trim().replaceAll(RegExp(r'/+$'), ''),
orgId: _orgId.text.trim(),
appId: _appId.text.trim(),
appKey: _appKey.text.trim(),
appSecret: _appSecret.text.trim(),
logRequests: true,
);
try {
await widget.state.connect(config);
// Force the app token now rather than on the first real call, so a wrong
// key is reported here instead of on the sign-in screen later.
await widget.state.appmint!.http.appToken();
} on AppmintException catch (e) {
widget.state.disconnect();
setState(() => _error = e.message);
}That second call is a deliberate choice. Without it a wrong app key surfaces later as a confusing sign-in failure; with it, the screen that asked for the key is the screen that reports it.
Put the honest warning on this screen too — the person reading a demo is exactly the one about to paste credentials somewhere they should not:
These identify your app; they do not protect it. Anyone can read them out of a shipped binary, so treat them as a name rather than a password. What protects a request is the signed-in person's token.
Run it now. Connect, and the log gains one row: POST /profile/app/key,
app token lit, user token dim. You have not written a header.
Step 5 — Sign somebody in
lib/screens/sign_in_screen.dart. Start with the switch that decides who is
signing in:
SegmentedButton<Identity>(
segments: const [
ButtonSegment(value: Identity.customer, label: Text('Customer')),
ButtonSegment(value: Identity.staff, label: Text('Staff')),
],
selected: {_identity},
onSelectionChanged: (s) => setState(() => _identity = s.first),
),Staff and customers are different records on the server with different sign-in routes. Keep the distinction on screen — hiding a real difference turns it into a mystery when it misbehaves.
Now the handler the whole example exists for:
Future<void> _handle(Future<SignInResult> attempt) async {
setState(() { _busy = true; _error = null; });
final result = await attempt;
if (!mounted) return;
switch (result) {
case SignedIn():
break; // the auth stream rebuilds onto the session screen
case NeedsVerification(:final challenge):
setState(() => _busy = false);
final finished = await showVerificationSheet(context, challenge);
if (finished is SignInRejected) setState(() => _error = finished.message);
return;
case SignInRejected(:final message):
setState(() => _error = message);
}
if (mounted) setState(() => _busy = false);
}This is the bit to copy verbatim. A verification challenge comes back as an
ordinary 200 with no token in it. Read as success it produces an app that
looks signed in, holds nothing, and throws the person out on the next call —
that is not hypothetical, it is how two shipped apps locked their users out. The
sealed result makes the mistake impossible: the switch will not compile with a
case missing.
Because _handle takes a Future<SignInResult>, every route in reuses it:
_handle(_who.signIn(email, password));
_handle(_who.verifyMagicCode(email, code));
_handle(_appmint.staff.signInWithPasscode(employeeId: id, pin: pin));
_handle(_appmint.auth.signInWithSaved(session));Add the shared-device block under a if (_identity == Identity.staff) — employee
id and passcode, for a till or a door scanner where typing a password in front of
a queue is the slowest thing in the building.
Step 6 — The verification sheet
lib/screens/verify_sheet.dart. Three details decide whether somebody gets in
or gives up.
A wrong code does not end the attempt. The challenge stays alive, so stay open and show the server's own wording:
final result = await challenge.verify(code);
switch (result) {
case SignedIn():
Navigator.pop(context, result);
case SignInRejected(:final message):
setState(() { _busy = false; _error = message; }); // let them try again
case NeedsVerification():
setState(() => _busy = false);
}Do not offer to resend an authenticator code. It is computed on their device; there is nothing to send:
if (challenge.method.canResend)
TextButton(onPressed: () => _send(null, 'Code sent again'), …),Give them a way out. If the second factor is on a phone that is lost, flat or in another room, any enrolled factor can still answer the challenge:
if (method != 'email')
TextButton(
onPressed: () => _send('email', 'Code sent to your email'),
child: const Text('Email me a code instead'),
),Word the prompt for the method in play — "sent to [email protected]", "sent by text", "in your authenticator app". A sheet that says "enter the code" when the code went to a phone the person is not holding is where they give up.
Step 7 — The session
lib/screens/session_screen.dart shows who is signed in, then proves it:
final page = await _appmint.repository.find('setting', pageSize: 1);
setState(() => _probe =
'Read ${page.items.length} of ${page.total} settings as ${_user.email}.');Run that and watch the log: this row has both badges lit. That is the moment the two-token model stops being an abstraction.
Finish with a plain statement of what signing out does, because it is narrower than people assume:
Clears the tokens and the cached person — and nothing else. Accounts remembered on this device survive on purpose, and so does anything your app stored: a shared till should not lose its paired printer because somebody went home.
FilledButton.tonal(
onPressed: () => _appmint.auth.signOut(),
child: const Text('Sign out'),
)Step 8 — Run the whole thing
flutter run -d chromeIn order, you should see:
- The Connect screen. Fill in your organization.
- One row in the log —
POST /profile/app/key, app token lit. - The sign-in screen. Pick Customer or Staff and sign in.
POST /profile/customer/signin— still only the app token, because there is no session yet.- Either the session screen, or the verification sheet if your organization asks for a code.
POST /profile/security/challenge/verifyafter you enter it.- Make a call as this person — and finally, a row with both badges lit.
When it does not work
| What you see | What it means |
|---|---|
| The app could not authenticate with appengine | The app id, key, secret or org id is wrong. Nothing else will work until this passes |
| Could not reach appengine at … | Wrong URL, or nothing listening. Check the scheme and drop any trailing slash |
| bad password | The server refused the person's credentials, not the app's |
| The sheet appears but no code arrives | It went to the address on that account; on a dev deployment mail may not be delivered at all |
| That verification session has expired | More than ten minutes passed — sign in again |