Skip to content

Authentication

View raw .md
On this page

Introduction#

The Magic Starter authentication module provides a complete, feature-gated authentication system built on top of the Magic Framework. It covers login, registration, password reset, two-factor authentication, social login, phone OTP, and guest authentication — all driven by opt-in configuration flags.

Three controllers split the responsibilities:

Controller Scope
MagicStarterAuthController Login, register, forgot/reset password, two-factor challenge, logout
MagicStarterGuestAuthController Device-ID-based guest login
MagicStarterOtpController Phone OTP send and verify

Every controller follows the Magic singleton pattern (static T get instance => Magic.findOrPut(T.new)) and mixes in MagicStateMixin, ValidatesRequests, and NavigatesRoutes.

Login Flow#

Call MagicStarterAuthController.instance.doLogin() with the user's credentials. The controller builds the identity payload based on the active identity mode, sends POST /auth/login, and handles the response:

dart
await MagicStarterAuthController.instance.doLogin(
  email: form['email'],
  phone: form['phone'],
  password: form['password'],
  rememberMe: form.value<bool>('remember_me'),
);

The login flow has three possible outcomes:

  1. Success: the controller hands the body to CompletesSignIn.completeSignIn(), the one path every token takes (password, social, two-factor challenge, OTP and guest): it extracts token and user from the nested data key, calls Auth.login(), shows the social.deletion_cancelled toast when data.deletion_cancelled is true (a sign-in during the grace period of a scheduled account deletion cancels it), sets success state, and calls navigateHome(). That is the intended url a bounced deep link recorded, read once through MagicRouter.pullIntendedUrl(), and MagicStarterConfig.homeRoute() only as the fallback when nothing was recorded or the recorded value is not an in-app path. An intent belongs to the session that asked for it: MagicStarterServiceProvider listens to Auth.stateNotifier and discards it whenever the state goes to signed-out, so it never reaches the next person on the device. That covers the sign-out a user asks for, the account deletion, and the one the app performs on its own when a token refresh fails, since all three go through the same notifier. No host wiring is needed; the provider boots in every starter app.
  2. Two-factor required — the controller detects the challenge flag and navigates to MagicStarterConfig.twoFactorChallengeRoute() with the encrypted token as a query parameter. No login occurs yet.
  3. Failure — handleApiError() sets the error state with a localized fallback message.

A _isSubmitting guard prevents double-submit — concurrent calls to doLogin() are silently discarded.

The login, register, forgot-password and reset-password views hint their fields (username/email, password, newPassword, telephoneNumber) so a password manager or the OS keychain can fill them. The login, register and reset-password forms sit in an AutofillGroup that cancels on dispose, and the login and register forms stay mounted while the request is in flight; the context is committed (TextInput.finishAutofillContext(shouldSave: true)) only after an email and password sign-in or a registration has logged a session in and is about to navigate (completeSignIn(saveCredentials: true)), so a failed attempt, a provider or one-time-code sign-in, and a background guest session are never offered for saving.

Auth.login() dispatches magic's AuthLogin event, which MagicStarterServiceProvider listens for to call the host's MagicStarter.useLogin() hook (see Login Callback), unawaited and logged on failure. It fires for every sign-in path this package ships: password (above), two-factor challenge, social login, guest login (MagicStarterGuestAuthController.doGuestLogin()) and OTP verification, and never for a cold-boot restore of a stored session.

Registration#

doRegister() collects name, email/phone (per identity mode), password, and password_confirmation:

dart
await MagicStarterAuthController.instance.doRegister(
  name: form['name'],
  email: form['email'],
  phone: form['phone'],
  password: form['password'],
  passwordConfirmation: form['password_confirmation'],
  subscribeNewsletter: form.value<bool>('subscribe_newsletter'),
);

When the newsletter feature is active and subscribeNewsletter is true, the payload includes subscribe_newsletter: true.

The response determines the post-registration flow:

  • Auto-login — when the server returns token + user in data, the controller calls Auth.login() and navigates home.
  • Email verification required — when no credentials are returned, the controller navigates to the login page so the user can verify their email first.

Forgot Password#

Sends a password reset link via POST /auth/forgot-password:

dart
await MagicStarterAuthController.instance.doForgotPassword(
  email: form['email'],
);

On success, the controller sets success state. The view should display a confirmation message. No navigation occurs — the user stays on the same page.

Verify Email#

Registered with features.email_verification. The verification mail of magic-starter-laravel 0.0.41 and later opens /auth/verify-email?id=...&hash=...&expires=...&signature=.... MagicStarterVerifyEmailView reads the four query parameters once in didChangeDependencies and calls doVerifyEmail, which replays them as GET /email/verify/{id}/{hash}?expires=...&signature=...; the API's signed middleware checks them against its own host. On success a signed-in user is restored so email_verified_at lands, and the page offers Continue (home) or Back to Sign In. Any refusal (a bad or expired signature, a changed address), and any link whose id is not key-shaped or whose hash is not a sha1, shows magic_starter.email_verification.verify_failed; the malformed link is never sent. A reader signed in as a different user sees verify_success_other instead of the second-person copy.

The route sits outside the guest group: the link is opened signed in as often as signed out, and guest would send a signed-in reader home before the link was replayed.

Landing After Sign-In (?next=)#

MagicStarterLoginView and MagicStarterRegisterView read a next query once when they open. A valid in-app path becomes the router's intended url (MagicRouter.setIntendedUrl), so the sign-in or sign-up that follows from that screen (password, social, two-factor, OTP, registration) lands there through navigateHome(). Without next, or with one that is not valid, the screens record nothing and leave an intent EnsureAuthenticated recorded alone.

A valid next has exactly one leading / (//host is another origin), at most 512 characters, no backslash, no whitespace, separator or other \p{C} character (controls, format characters such as U+202E, private use, unassigned), the same rule as the backend's SameAppPath, no .. path segment (percent-encoded included). Anything else is ignored silently.

Sign-In Handoff#

Registered with features.handoff. Another surface that has already authenticated the visitor (a landing page, a mail) asks magic-starter-laravel for a one-time code (POST auth/handoff, authenticated) and opens the app at /auth/handoff?code=<code>&next=<path>, on the web or through a Universal Link or App Link that resolves to the same route. No bearer token travels in a url.

MagicStarterHandoffView reads code and next once and calls MagicStarterHandoffController.handOff:

  1. With a code, the controller posts it to POST auth/handoff/exchange. The answer is a login answer plus data.next, held in memory only.
    • The page asks "Sign in as ?" with Continue and Cancel before anything changes, with or without an open session. Without one the question is what stops a login CSRF: a link carrying an attacker's own code would otherwise sign the visitor in to the attacker's account. handoff.confirm_without_session (default true) is the one knob: an app may set it to false to skip that tap when no session is open; over an open session the page always asks.
    • Continue signs any open session out through MagicStarterAuthController.logout() (before-logout hooks included), records data.next (validated like ?next=; the query next is ignored once a code is exchanged, since it is not bound to the code) as the intended url and concludes through completeSignIn, so a two-factor answer opens the challenge and its success lands on next too.
    • Cancel goes home with an open session untouched, or to the sign-in screen without one. The exchange already minted a token on the server (and cancelled a scheduled account deletion), so a declined answer, one dropped by leaving the page, and one that lands after the page closed are revoked with POST auth/logout presenting that token on the same bare driver: best effort, never logged, never touching the open session. A two-factor answer carries no token and revokes nothing.
    • A refused code (the backend's one 422 shape, code: handoff_expired), a transport failure or an answer that is neither a session nor a challenge changes nothing: home over a session, the sign-in screen without one, with one toast (magic_starter.handoff.expired) that never tells which of those it was.
  2. Without a code but with a valid next: signed in, the visitor goes to next; signed out, to handoff.signed_out_route (default routes.login) carrying ?next=, which the login and register screens honour.
  3. With neither: home when signed in, the sign-in screen otherwise.

The route sits outside the guest group and carries no auth middleware: a visitor with an open session has to reach the confirmation. The exchange goes out on a bare DioNetworkDriver with the Http facade driver's origin, timeout and default headers but none of its interceptors, so magic's AuthInterceptor neither attaches the open session's bearer nor answers a 401 with its refresh-or-logout path, and no devtools recorder keeps the answer, which carries the new token. The code, the token and the handed email are never logged; the email (or name) appears only on the confirmation. An app overrides the page through the registry key auth.handoff.

The code stays in the browser history entry of /auth/handoff?code=.... It is single use, but removing it from history is the app's job; the starter does not rewrite the address bar. A web host should also send <meta name="referrer" content="strict-origin"> so the code never rides a same-origin Referer into a server log.

The exchange mints its token before the visitor confirms. A visitor who closes the tab on "Sign in as ...?" leaves that token alive until it is revoked by hand, because the page can only revoke on Cancel or when it is left through the app.

Reset Password#

Completes the password reset using the token from the email link, /auth/reset-password?token=...&email=.... Both values are query parameters, not path segments. The view reads them once in didChangeDependencies through MagicRouter.instance.queryParameterOf(context, ...), which answers for the page the view sits in; initState cannot read route state, and the static queryParameter() answers for whichever page was built last:

dart
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  if (_token != null) return;

  _token = MagicRouter.instance.queryParameterOf(context, 'token') ?? '';
  _email = MagicRouter.instance.queryParameterOf(context, 'email') ?? '';
}

await MagicStarterAuthController.instance.doResetPassword(
  token: _token!,
  email: _email!,
  password: form['password'],
  passwordConfirmation: form['password_confirmation'],
);

On success, the controller sets success state. The view should display a confirmation and provide a link back to login.

Two-Factor Authentication#

Two-factor authentication spans two contexts: challenging (during login) and managing (in profile settings).

Two-Factor Challenge#

When doLogin() detects a two-factor requirement, it navigates to the challenge view with the encrypted two_factor_token. The user enters either a TOTP code from their authenticator app or a recovery code:

dart
await MagicStarterAuthController.instance.doTwoFactorChallenge(
  twoFactorToken: token,
  code: form['code'],         // TOTP from authenticator
  // OR
  recoveryCode: form['recovery_code'],  // One-time recovery code
);

Exactly one of code or recoveryCode must be provided — the controller asserts this invariant. On success, the flow completes the same as a normal login: Auth.login() followed by navigation to home.

Enabling Two-Factor#

Enabling 2FA is a multi-step process managed by MagicStarterProfileController:

  1. Enable: doEnableTwoFactor(proof:) calls POST /two-factor-authentication. Returns a map containing secret, qr_url, qr_svg, and recovery_codes.
  2. Confirm — doConfirmTwoFactor(code:) calls POST /two-factor-authentication/confirm with the TOTP code from the authenticator app.
  3. Disable: doDisableTwoFactor(proof:) uses Laravel method spoofing ({'_method': 'DELETE'}) via Http.post().

Recovery Codes#

Recovery codes can be viewed and regenerated via MagicStarterProfileController:

dart
// View existing codes (requires a step-up proof)
final codes = await controller.getRecoveryCodes(proof: proof);

// Regenerate codes (requires a step-up proof)
final newCodes = await controller.doRegenerateRecoveryCodes(proof: proof);

Both methods call endpoints under /two-factor-recovery-codes. Like enabling and disabling 2FA, they take a proof map: {'password': ...} for an account with a password, {'code': ...} or {'confirmation_token': ...} for one without, {} for a guest. See Confirming Identity for how the starter produces one.

Social Login#

Social login is feature-gated via magic_starter.features.social_login. The starter owns the screens (the "Continue with" buttons on login and register, the Connected accounts page, the identity-confirmation dialog) and what a sign-in concludes (a session, a two-factor challenge, a cancelled deletion). The provider SDKs and the backend calls belong to a bridge the app registers, so this package depends on no social login package.

dart
MagicStarter.useSocialAuth(AppSocialAuth());

While a bridge is registered and the feature is on, login and register render one button per provider the bridge offers, and the Connected accounts page is reachable from the settings hub. Without a bridge the buttons and the hub row are absent: the feature flag alone renders nothing. The page's route is registered on the feature alone, since the bridge is set after the routes are.

magic_social_auth installs the bridge for you: social:install publishes lib/app/providers/social_auth_starter_service_provider.dart, which implements the contract below over SocialAuth and calls MagicStarter.useSocialAuth. See its installation guide.

The MagicStarterSocialAuth Contract#

dart
abstract class MagicStarterSocialAuth {
  List<String> providers();                       // in display order: google, apple, ...
  String label(String provider);                  // 'Google'
  Widget icon(String provider);

  Future<Map<String, dynamic>> signIn(String provider);

  Future<Future<Map<String, dynamic>> Function()> beginConnect(
    String provider,
    Map<String, String> proof,
  );

  Future<String> confirm(String provider);        // the confirmation token
  Future<void> signOut();
}
Method Contract
signIn(provider) Answers the backend's body untouched: a session ({data: {user, token}}, plus data.deletion_cancelled when the sign-in cancelled a scheduled deletion) or a challenge ({two_factor: true, two_factor_token}). Called synchronously from the user's tap, with no await before it, so a web implementation can still open its popup. A newer call supersedes a pending one.
beginConnect(provider, proof) Starts linking a provider to the signed-in account and answers the call that finishes it. proof is minted fresh for every call and empty for a guest. Whatever needs the network before the provider opens (a link ticket) happens here, so the returned call opens the provider before its first await: on the web the starter runs it from a fresh user tap, elsewhere at once. It answers {data: {provider, email}}.
confirm(provider) Re-authenticates the signed-in user with a linked provider and answers the confirmation token the backend issued.
signOut() Signs out of every provider SDK that keeps a session of its own. The starter does not call it; the bridge social:install publishes signs Google out itself whenever Auth.stateNotifier goes to signed-out, and a bridge of your own has to do the same.

Every failure surfaces as a MagicStarterSocialException(code:, message:, cancelled:). cancelled marks the user backing out, which the starter shows nothing for. Otherwise the starter shows the social.<code> sentence (social_email_taken, flow_expired, step_up_required, ...) and falls back to message.

What a Social Sign-In Does#

MagicStarterAuthController.doSocialSignIn(provider) calls the bridge before anything is awaited, then concludes through the same completeSignIn as every other sign-in: a two-factor answer opens the challenge, a session logs in and goes home, and deletion_cancelled shows the "Account deletion cancelled" toast. A cancelled flow shows nothing; a pending flow does not block a new tap.

Language Keys#

The screens read a social.* group (continue_with, connect, disconnect, connected_accounts, confirm_identity, confirm_with, step_up_required, deletion_cancelled, deletion_scheduled, and one sentence per backend refusal code), profile.set_password, profile.set_password_description, profile.password_set, profile.password_set_failed and magic_starter.titles.connected_accounts. A fresh starter:install writes them to assets/lang/en.json; an existing app adds them from assets/stubs/install/en.stub.

OTP Flow#

The phone OTP flow is a two-step process managed by MagicStarterOtpController. It requires both guest_auth and phone_otp feature flags to be enabled.

Sending an OTP#

Step 1 collects the phone number and sends the OTP via POST /auth/otp/send:

dart
await MagicStarterOtpController.instance.sendOtp(
  phone: '+905301234567', // E.164 format
);

On success, the controller transitions from OtpStep.phoneInput to OtpStep.codeInput and stores the phone number internally for step 2.

Verifying an OTP#

Step 2 verifies the 6-digit code via POST /auth/otp/verify:

dart
await MagicStarterOtpController.instance.verifyOtp(
  phone: controller.phoneNumber ?? '',
  code: form['code'],
);

On success, the controller calls Auth.login() with the returned token and navigates home. Call controller.resetToPhoneInput() to restart the flow.

Guest Sessions#

MagicStarterGuestAuthController signs a visitor in as a guest with POST /auth/guest, sending a device id it keeps in Vault under guest_device_id, so the backend recognises a returning guest. Two entry points share the request:

  • doGuestLogin() is the login screen's "Continue as guest": it opens the session, then goes home.
  • openGuestSession() opens one in the background and leaves the viewer where they are, for a host that needs a session before anyone asks for one. It resolves true when a session exists afterwards and false when none could be opened (the reason is in the controller's error state); it never throws.
dart
final bool hasSession = await MagicStarterGuestAuthController.instance.openGuestSession();

A session already held is kept and no request goes out: a guest's, because a second POST /auth/guest revokes every token that guest holds, and an account's, signed in or only stored, because a guest never replaces an account. Concurrent callers share the request in flight and its result; once it settles, the next call tries again.

Guest Claim#

With guest_auth on, what a guest accumulated moves to the account they sign in to next. MagicStarterGuestClaim runs the flow, and MagicStarterServiceProvider wires it to magic's auth events:

  1. Guest sign-in (AuthLogin for a user whose is_guest is true): the guest's bearer token and user id are written to Vault under MagicStarterGuestClaim.tokenKey (guest_claim_token) and MagicStarterGuestClaim.userKey (guest_claim_user). The write is local and awaited.
  2. Sign-in or restore of a real account (AuthLogin for a non-guest, or AuthRestored): claimIfPending() runs unawaited, so it never holds up Auth.login or a cold-boot Magic.init. When the recorded id equals the signed-in id (registration promoted the guest row in place), the record is dropped with no request. Otherwise it posts POST /auth/guest/claim with {'guest_token': <guest token>} and Authorization: Bearer <signed-in token>. A 2xx settles as claimed, a 404 (the backend has no claim route) or a 422 settles as refused, and either drops the record; anything else keeps it for the next sign-in or restore.
  3. Sign-out (AuthLogout): MagicStarterGuestClaim.forget() deletes the record before Auth.logout() returns, so the next person to sign in on the device cannot claim the previous viewer's rows. It also ends the session a claim in flight belongs to: that claim's answer is dropped, it settles as none, it never touches a guest recorded after it, and the next sign-in starts its own claim.

Every outcome but GuestClaimOutcome.none reaches MagicStarter.useGuestClaimed() (see Guest Claim Callback).

An app that needs its own ordering after the claim (a refetch that must see the moved rows, say) calls it directly:

dart
final GuestClaimOutcome outcome = await MagicStarterGuestClaim.instance.claimIfPending();

A claim already in flight hands every caller in the same session the same future, so this never posts the token twice.

The claim does not ride the Http facade. Its body is the guest's live bearer token, and the facade's driver is the one magic_devtools records request bodies from in debug and profile builds. It goes out on a bare DioNetworkDriver built from network.drivers.api (base_url, timeout, headers) with no interceptors. That driver sends no magic User-Agent: NetworkServiceProvider adds that header only to the facade driver, so set one in network.drivers.api.headers if the backend needs it on this request.

Logout#

There are two sign-out paths a user can take, and both end in Auth.logout():

  • MagicStarterAuthController.instance.logout(): runs the before-logout hooks, stops notification polling and the push identity (when the notifications feature is on), calls Auth.logout(), then navigates to MagicStarterConfig.loginRoute().
  • The profile dropdown's sign-out item: calls your MagicStarter.useLogout() callback when one is registered, after running the before-logout hooks, and the controller path above otherwise. The hooks run once either way.

Before-Logout Hooks#

Some work needs the session token and has to happen before it is dropped: releasing this device's push registration on your backend, revoking a server-side session, signing out of a social SDK. Register it once, from a service provider boot():

dart
MagicStarter.beforeLogout(() => SocialAuth.signOut());

The contract:

  • Hooks run in registration order, before a custom useLogout() callback and before Auth.logout(), on both paths above.
  • Each hook is isolated: a throw is logged with Log.error and the next hook runs.
  • Each hook is bounded to five seconds (MagicStarterManager.beforeLogoutTimeout); a dead network cannot trap somebody who asked to leave.
  • Registering the same hook twice keeps one copy.
  • A sign-out button of your own calls await MagicStarter.manager.runBeforeLogoutHooks() before its own Auth.logout().

When the magic_notifications package is bound and its push-state reporting is configured (notifications.push_state.report_path), MagicStarterServiceProvider registers Notify.pushState.release as a default hook, so the backend stops treating this device as reachable for the person who just left it. Nothing to wire.

It also registers a hook that revokes the token on the server, POST /auth/logout with the still-valid bearer, so a sign-out ends the session on the backend rather than only forgetting the token on the device. It runs after the push release, sends nothing when no token is held, and logs a refused or failed request without holding up the sign-out. A hook you register after MagicStarterServiceProvider boots runs after the revoke, on a dead token.

Onboarding Steps#

MagicStarterOnboarding is a one-time gate for first-run steps (a locale confirmation, a feature tour, a welcome banner): each step is shown once per device and never again. Each step persists under its own Vault key (onboarding_<step>_done), and every loaded step is mirrored in memory so a synchronous build can read it.

Load the steps your app cares about once, after Magic.init(), then read and flip them from the screens that own them:

dart
await MagicStarterOnboarding.load(['locale', 'tour']);

if (!MagicStarterOnboarding.isCompleted('tour')) {
  // Show the tour, then:
  await MagicStarterOnboarding.markCompleted('tour');
}
Member Purpose
load(steps) Reads every step's flag from Vault. A missing key, or a Vault read that fails (logged as a warning), reads as not completed.
isCompleted(step) Whether the step was resolved on this device. Synchronous.
markCompleted(step) Flips the flag in memory at once and persists it.
vaultKeyFor(step) The Vault key a step persists under.

The flags are per device, not per account: they survive sign-out and a sign-in as someone else.

Identity Modes#

The starter supports three identity modes configured via magic_starter.auth.email and magic_starter.auth.phone:

auth.email auth.phone Mode Behavior
true false Email-only (default) Only email field is sent in the payload
false true Phone-only Only phone field is sent in the payload
true true Both Both fields sent when non-empty; phone takes precedence when resolving a single identity

The _applyIdentityToPayload() helper centralizes this logic across login and registration. In "both" mode, every non-empty identity field is included so the backend receives the full picture.

dart
// Config for phone-only mode
Config.set('magic_starter.auth.email', false);
Config.set('magic_starter.auth.phone', true);

Feature Gates#

Authentication features are controlled by these configuration toggles. All default to false.

Config Key Gate Method Affects
magic_starter.features.registration hasRegistrationFeatures() Register view and route
magic_starter.features.social_login hasSocialLoginFeatures() Social login buttons on login/register, the Connected accounts page and its settings row (all need a bridge from MagicStarter.useSocialAuth), and the "Confirm with a provider" option of the identity-confirmation dialog
magic_starter.features.two_factor hasTwoFactorFeatures() 2FA enable/disable in profile, challenge route
magic_starter.features.guest_auth hasGuestAuthFeatures() Guest login flow
magic_starter.features.phone_otp hasPhoneOtpFeatures() Phone OTP send/verify
magic_starter.features.newsletter hasNewsletterFeatures() Newsletter checkbox on registration
magic_starter.features.email_verification hasEmailVerificationFeatures() Email verification flow
magic_starter.features.handoff hasHandoffFeatures() Sign-in handoff route and view
magic_starter.auth.email emailIdentity() Email field in login/register (default true)
magic_starter.auth.phone phoneIdentity() Phone field in login/register (default false)

Controllers#

Controller Singleton Key Methods
MagicStarterAuthController .instance doLogin(), doRegister(), doForgotPassword(), doResetPassword(), doTwoFactorChallenge(), logout()
MagicStarterGuestAuthController .instance doGuestLogin(), openGuestSession(), isGuestUser, getStoredDeviceId()
MagicStarterOtpController .instance sendOtp(), verifyOtp(), resetToPhoneInput(), step, phoneNumber
MagicStarterHandoffController .instance handOff(), confirm(), cancel(), forget(), isConfirming, confirmingAs

All controllers use the NavigatesRoutes mixin for safe navigation. Never call context.go() directly in a controller — the navigator context may not be mounted. The mixin checks MagicRouter.instance.navigatorKey.currentContext and no-ops when unavailable.

After successful login or registration, Auth.login() stores the token and user model. Call Auth.restore() when you need to refresh the user model from the backend (e.g., after profile updates).

Views#

All views extend MagicStatefulView<ControllerType> and are registered in the view registry with string keys:

Registry Key View Layout
auth.login LoginView GuestLayout
auth.register RegisterView GuestLayout
auth.forgot_password ForgotPasswordView GuestLayout
auth.reset_password ResetPasswordView GuestLayout
auth.verify_email VerifyEmailView GuestLayout
auth.two_factor_challenge TwoFactorChallengeView GuestLayout
auth.otp_verify OtpVerifyView GuestLayout
auth.handoff HandoffView GuestLayout

Views are instantiated through the registry (MagicStarter.view.make('auth.login')) and wrapped in the appropriate layout (MagicStarter.view.makeLayout('layout.guest', child: view)). The host app can override any view by registering a custom builder under the same key.

Views contain zero business logic — all async operations, state management, and navigation decisions live in the controllers.


Related links:

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.