my_otp_way 0.1.0
my_otp_way: ^0.1.0 copied to clipboard
Send and verify OTP codes through your own backend, without shipping an API key inside your app.
my_otp_way #
Send and verify OTP codes from a Flutter app without shipping an API key
inside it. Anyone can unpack a published app and read its secrets, so this
package never talks to the MY-OTP-Way API at all —
it talks to three routes on your backend, which holds the key. What you get
here is the part every app re-implements and gets wrong: the expiry countdown,
the resend cooldown and the attempts counter, in one ChangeNotifier with a
typed error for every way the flow can fail.
Headless by design. It ships no OTP field, no theme and no opinions about what your screen looks like.
Install #
dependencies:
my_otp_way: ^0.1.0
import 'package:my_otp_way/my_otp_way.dart';
How it works #
┌──────────────────┐ ┌────────────────────────┐ ┌─────────────────┐
│ Your Flutter │ POST │ Your backend │ │ MY-OTP-Way API │
│ app │ ─────▶ │ myotpway/laravel-sdk │ ─────▶ │ │
│ (this package) │ │ MyOtpWay::routes() │ │ │
│ │ │ │ │ │
│ no API key │ │ holds the API key │ │ │
└──────────────────┘ └────────────────────────┘ └─────────────────┘
Your backend publishes three public routes — POST {prefix}/send,
POST {prefix}/resend, POST {prefix}/verify (prefix is my-otp by
default). With the
Laravel package that is
one line:
// routes/api.php — NOT routes/web.php.
// web.php attaches session + CSRF middleware, and a mobile binary cannot carry
// a CSRF token, so every POST from this package would come back 419.
MyOtpWay::routes();
Then point the client at your own domain:
final client = MyOtpWay(baseUrl: 'https://api.your-backend.example');
prefix and timeout are constructor arguments if you changed either on the
server: MyOtpWay(baseUrl: ..., prefix: 'auth/otp'). Call client.close()
when you are done with it.
A screen, end to end #
OtpSession is a ChangeNotifier. It notifies on every state change and
once a second while a countdown is running, so the timers below tick without
any Timer of your own.
class VerifyScreen extends StatefulWidget {
const VerifyScreen({required this.phone, super.key});
final String phone;
@override
State<VerifyScreen> createState() => _VerifyScreenState();
}
class _VerifyScreenState extends State<VerifyScreen> {
final MyOtpWay _client =
MyOtpWay(baseUrl: 'https://api.your-backend.example');
late final OtpSession _session =
OtpSession(client: _client, phone: widget.phone);
@override
void initState() {
super.initState();
_session.send();
}
@override
void dispose() {
_session.dispose();
_client.close();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _session,
builder: (BuildContext context, Widget? child) => Column(
children: <Widget>[
Text('Status: ${_session.status.name}'),
Text('Expires in ${_session.expiresIn?.inSeconds ?? 0}s'),
TextField(onSubmitted: _session.verify),
TextButton(
onPressed: _session.canResend ? _session.resend : null,
child: Text(
_session.canResend
? 'Resend'
: 'Resend in ${_session.resendAvailableIn.inSeconds}s',
),
),
if (_session.attemptsRemaining case final int left)
Text('$left attempts left'),
if (_session.error case final MyOtpException e)
Text(describeOtpError(e)),
],
),
);
}
describeOtpError is below. A runnable version of this whole
screen — with a phone field, both countdowns and every getter on show — is in
example/.
What the session owns #
| Getter | Type | |
|---|---|---|
status |
OtpSessionStatus |
idle, sending, awaiting, verifying, verified, failed |
requestId |
String? |
set once a send succeeds |
expiresIn |
Duration? |
counts down to zero; null before the first send |
resendAvailableIn |
Duration |
counts down to zero |
canResend |
bool |
false until a code has been sent and the cooldown has elapsed |
attemptsRemaining |
int? |
after a wrong code, when the server said |
error |
MyOtpException? |
the last failure; cleared by the next successful call |
And three methods: send(), resend(), verify(code). All three return
Future<void> and never throw — a failure lands in error, which is the
point of a state machine. Call dispose() when the screen goes away.
Three refusals never reach the network at all, because the answer is already known locally:
resend()during the cooldown setserrortoResendTooSoonand stays inawaiting. This is what stops a user draining your balance by holding the button down.send()after a successful send honours the same cooldown, so two taps on "Send" cost one message, not two.verify()before any send setserrortoInvalidRequest;resend()before any send sets it toNothingToResend.
A wrong code returns the session to awaiting so the user can retype it.
Everything else — a locked code, an expired one, an outage — ends in failed.
MyOtpBuilder #
If you do not want to own a State yourself, MyOtpBuilder creates the
session, sends on mount, disposes on unmount and rebuilds your UI:
MyOtpBuilder(
// client and phone are read once, in initState. Key the widget on the
// number the user has SUBMITTED, so correcting a mistyped one builds a
// fresh session instead of silently verifying the old one.
key: ValueKey<String>(submittedPhone),
client: client,
phone: submittedPhone,
onVerified: () => Navigator.of(context).pushReplacementNamed('/home'),
builder: (BuildContext context, OtpSession session) => Column(
children: <Widget>[
Text('Status: ${session.status.name}'),
TextField(onSubmitted: session.verify),
TextButton(
onPressed: session.canResend ? session.resend : null,
child: Text(
session.canResend
? 'Resend'
: 'Resend in ${session.resendAvailableIn.inSeconds}s',
),
),
],
),
)
Never key on the live text of a phone field. sendOnInit sends from a
brand-new session, and a brand-new session has no requestId, so no cooldown
applies to it: every keystroke would mint a session and buy a message. Key on
a value that only changes when the user commits — the field's submitted value,
not its content.
onVerified fires exactly once. sendOnInit: false skips the automatic send.
Without the state machine #
MyOtpWay is usable on its own. Here every call either returns or throws:
Future<void> rawFlow() async {
final MyOtpWay client =
MyOtpWay(baseUrl: 'https://api.your-backend.example');
try {
final SendResult sent = await client.send('+9647701234567');
await client.verify(requestId: sent.requestId, code: '123456');
// The number is confirmed: verify() returns normally, or throws.
} on WrongCode catch (e) {
debugPrint('wrong code, ${e.attemptsRemaining} attempts left');
} on MyOtpException catch (e) {
debugPrint(describeOtpError(e));
} finally {
client.close();
}
}
send() and resend() return a SendResult (requestId, expiresAt,
resendAvailableIn). verify() returns void: success is the absence of a
throw.
Errors #
MyOtpException is sealed, so a switch over it needs no default arm
and the compiler will point at your code if a variant is ever added:
String describeOtpError(MyOtpException error) => switch (error) {
Forbidden() => 'Your backend refused this request.',
InvalidPhone() => 'That phone number does not look right.',
CountryNotAllowed() => 'That country is not enabled on this backend.',
NothingToResend() => 'Send a code before asking for another one.',
// availableIn is nullable: null means the server did not say how long,
// which is not the same as "resend now".
ResendTooSoon(:final Duration? availableIn) => availableIn == null
? 'Too soon to resend. Try again shortly.'
: 'Too soon to resend. Try again in ${availableIn.inSeconds}s.',
InvalidRequest() => 'The request was malformed.',
RateLimited(:final Duration? retryAfter) => retryAfter == null
? 'Too many requests. Try again later.'
: 'Too many requests. Try again in ${retryAfter.inSeconds}s.',
ServiceUnavailable() => 'The service is unavailable. Try again later.',
WrongCode(:final int? attemptsRemaining) => attemptsRemaining == null
? 'That code is wrong.'
: 'That code is wrong — $attemptsRemaining attempts left.',
CodeExpired() => 'That code expired. Ask for a new one.',
AlreadyUsed() => 'That code was already used.',
NotFound() => 'The server no longer knows about that request.',
TooManyAttempts() => 'Too many wrong codes. Start over.',
NetworkFailure() => 'Could not reach your backend.',
};
One variant per code in the proxy's error contract, plus one for a request that never left the device:
| Variant | Proxy error |
Extra property | When |
|---|---|---|---|
Forbidden |
forbidden |
— | your authorizeUsing() callback rejected the request |
InvalidPhone |
invalid_phone |
— | phone malformed, or the API rejected the recipient |
CountryNotAllowed |
country_not_allowed |
— | the number is outside your backend's allow-list |
NothingToResend |
nothing_to_resend |
— | /resend with no prior /send for that number |
ResendTooSoon |
resend_too_soon |
availableIn (Duration?) |
still inside the per-phone cooldown |
InvalidRequest |
invalid_request |
— | request_id or code missing or malformed |
RateLimited |
rate_limited |
retryAfter (Duration?) |
the per-IP throttle, or the API throttling your backend |
ServiceUnavailable |
service_unavailable |
— | anything the backend refuses to explain — and the fallback, see below |
WrongCode |
invalid_code |
attemptsRemaining (int?) |
wrong code, more attempts left |
CodeExpired |
expired |
— | the code expired before it was submitted |
AlreadyUsed |
already_used |
— | the code was already verified once |
NotFound |
not_found |
— | unknown request_id |
TooManyAttempts |
too_many_attempts |
— | attempt limit reached — a lockout, not a one-off wrong code |
NetworkFailure |
— | — | the request never reached your backend: DNS, TLS, a timeout |
Authenticating to your own proxy #
Forbidden is the one row above you have to design for rather than react to.
The Laravel package's authorizeUsing() decides who may call /my-otp/send,
and its default allows everyone. That default is deliberate, not an
oversight: for most apps the OTP is the login, so there is no session yet to
authenticate with. The endpoint is defended in depth instead — a per-IP
throttle on each route, a per-phone cooldown, and a country allow-list — and
none of that is auth.
So know what you are running. Anyone who reads your app's traffic once can call
/my-otp/send from curl, and every accepted call spends real money in a
currency you are billed in. The layers above bound how fast, not whether. If
your app does have something to authenticate with by the time it sends a code
— an existing session, a device attestation, a signed URL — use it.
This package's constructor takes no headers argument, deliberately: it is a
transport, not an auth framework. Send whatever your backend already
authenticates with — a bearer token, a signed URL, a device attestation
header — through httpClient, using package:http's BaseClient. Add
import 'package:http/http.dart' as http; and wrap the client you would
otherwise have used:
class AuthedClient extends http.BaseClient {
AuthedClient(this._inner, this._token);
final http.Client _inner;
final String _token;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers['authorization'] = 'Bearer $_token';
return _inner.send(request);
}
}
final MyOtpWay client = MyOtpWay(
baseUrl: 'https://api.your-backend.example',
httpClient: AuthedClient(http.Client(), sessionToken),
);
Every request this package makes then carries the header, and your
authorizeUsing() callback has something to check. Note that close() on a
client you passed in is yours to call — this one does not own it.
Two nullables worth reading twice. ResendTooSoon.availableIn and
RateLimited.retryAfter are Duration?, and null means the server did
not say — not zero. Rendering a null as 0s invites an immediate retry
straight into another refusal, so branch on it rather than defaulting it.
Likewise WrongCode.attemptsRemaining is int?: absent is not "no attempts
left".
Every variant also carries message (String?) — the server's own words,
when it sent any. The variant is what you branch on and what you write copy
against; this package deliberately localises nothing.
message is untrusted upstream text. Do not ship it anywhere. The proxy
never sends a message key, so in a correctly configured deployment this
field is always null — anything in it arrived from somewhere that is not
following the contract, most often a backend left on APP_DEBUG=true, and it
can therefore contain a stack trace, a SQL statement or a balance figure. On
mobile, "for your logs" means Crashlytics or Sentry: off-device, third-party,
retained. Show it to nobody, and redact it before it reaches a crash reporter.
ServiceUnavailable is the fallback for anything unrecognised: a body whose
error key this package does not know, a body with no error key at all
(a routing 404, a cache outage, an HTML error page from a proxy sitting in
front of your backend), or a 202 from /send that carries no request_id.
Guessing something more specific there would mean telling a user to retype a
code that was in fact correct.
The backend half #
The other side of these three routes is
myotpway/laravel-sdk.
Its README documents authorizeUsing(), the country allow-list, the per-phone
resend cooldown, the throttle limits and the error sanitisation that produces
the table above. Two of its notes change how this package behaves:
- The cooldown lives in your app's default cache store. On the
arraydriver it evaporates between requests; onfilebehind more than one node it is not shared. Either wayresend_available_instops meaning anything. Point the default cache at Redis in production. - The country allow-list defaults to
['+964']. A number outside it comes back asCountryNotAllowedbefore any request reaches the API.
SMS autofill is not included #
Deliberately. Reading the code out of an incoming SMS needs platform channels, an app signature hash on Android and an entitlement dance on iOS — all of it about the device, none of it about this protocol, and all of it at odds with this package's zero-plugin, pure-Dart footprint.
It composes cleanly instead: use pinput
for the code field and smart_auth to
retrieve the code, then hand the result to session.verify(code). Nothing
here needs to know where the string came from.
Testing your app #
MyOtpWay takes an httpClient, so
package:http's MockClient is enough to
drive a whole flow with no network:
final client = MyOtpWay(
baseUrl: 'https://x',
httpClient: MockClient((_) async => http.Response(
jsonEncode({
'request_id': 'req-1',
'expires_at':
DateTime.now().toUtc().add(const Duration(minutes: 5)).toIso8601String(),
'resend_available_in': 60,
}),
202,
)),
);
OtpSession reads time through
package:clock's ambient clock.now(), so
wrapping a test in fakeAsync lets async.elapse(...) drive both countdowns
without waiting in real time. Outside such a zone clock.now() is
DateTime.now(), so production behaviour is unchanged.
License #
MIT.