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
18
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