my_otp_way 0.1.0 copy "my_otp_way: ^0.1.0" to clipboard
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 sets error to ResendTooSoon and stays in awaiting. 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 sets error to InvalidRequest; resend() before any send sets it to NothingToResend.

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 array driver it evaporates between requests; on file behind more than one node it is not shared. Either way resend_available_in stops meaning anything. Point the default cache at Redis in production.
  • The country allow-list defaults to ['+964']. A number outside it comes back as CountryNotAllowed before 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.

0
likes
160
points
5
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Send and verify OTP codes through your own backend, without shipping an API key inside your app.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

clock, flutter, http

More

Packages that depend on my_otp_way