Guides

Custom Provider

Implement AuthProvider from scratch for any strategy Authyra doesn't ship.

CredentialsProvider and the OAuth2 family cover most cases. For anything else, magic link, phone OTP, a proprietary SSO, implement AuthProvider directly.


The interface

abstract class AuthProvider {
  String get id;
  bool get supportsRefresh => false;
  bool get supportsSignOut => false;

  Future<AuthSignInResult?> signIn({AuthSignInParams? params});
  Future<void> signOut({String? userId}) async {}
  Future<AuthTokenResult?> refreshToken(String refreshToken) async => null;
}

Only id and signIn are required. Everything else has a safe default.


Step 1: define your own params type

Don't reuse CredentialsSignInParams for a shape it wasn't built for. Extend AuthSignInParams:

class MagicLinkSignInParams extends AuthSignInParams {
  final String email;
  final String? token; // null while requesting, required to verify

  const MagicLinkSignInParams({required this.email, this.token});
}

Step 2: implement signIn

class MagicLinkProvider implements AuthProvider {
  @override String get id => 'magic-link';
  @override bool get supportsRefresh => false;
  @override bool get supportsSignOut => false;

  @override
  Future<AuthSignInResult?> signIn({AuthSignInParams? params}) async {
    final magicParams = params is MagicLinkSignInParams ? params : null;
    final token = magicParams?.token;
    if (token == null) return null;

    final res = await myApi.post('/auth/magic/verify', body: {'token': token});
    if (res.statusCode != 200) return null; // invalid or expired token

    return AuthSignInResult(
      user: AuthUser(id: res.data['id'], email: res.data['email']),
      accessToken: res.data['accessToken'],
      refreshToken: res.data['refreshToken'],
      expiresAt: DateTime.parse(res.data['expiresAt']),
    );
  }

  @override
  Future<void> signOut({String? userId}) async {}

  @override
  Future<AuthTokenResult?> refreshToken(String refreshToken) async => null;
}

Return null for an expected failure (wrong credentials, invalid token); throw an AuthException subclass for infrastructure errors (network, malformed server response). null becomes AuthenticationFailedException automatically, a throw propagates as-is.

Step 3: register it

final client = AuthyraClient(
  providers: [MagicLinkProvider()],
  storage: SecureAuthStorage(),
);

Step 4: sign in

await Authyra.instance.signIn('magic-link', params: MagicLinkSignInParams(
  email: 'alice@example.com',
  token: linkToken,
));

Adding refresh support

class MyApiProvider implements AuthProvider {
  @override bool get supportsRefresh => true;

  @override
  Future<AuthTokenResult?> refreshToken(String refreshToken) async {
    final res = await myApi.post('/auth/refresh', body: {'refreshToken': refreshToken});
    if (res.statusCode != 200) return null;
    return AuthTokenResult(
      accessToken: res.data['accessToken'],
      expiresAt: DateTime.parse(res.data['expiresAt']),
    );
  }
  // ...
}

See Token Refresh → for the retry semantics this plugs into.

Adding server-side sign-out

@override bool get supportsSignOut => true;

@override
Future<void> signOut({String? userId}) async {
  // Must never throw: catch and log internally.
  try {
    await myApi.post('/auth/logout');
  } catch (e) {
    log.warning('Server-side sign-out failed', e);
  }
}

Design rules

  • Stateless: hold configuration, never session state. Session state lives in SessionManager.
  • Return null on credential failure, don't throw for an expected "wrong password".
  • Throw for infrastructure errors: network failures, malformed config, CSRF mismatches.
  • signOut must never throw: a failed server-side revocation shouldn't block local cleanup.
  • Populate token fields when available: leaving accessToken as null disables getAccessToken() and silent refresh for this provider.

Testing a custom provider

test('signs in with a valid magic link token', () async {
  final client = AuthyraClient(
    providers: [MagicLinkProvider()],
    storage: InMemoryStorage(),
  );
  await client.initialize();

  final user = await client.signIn('magic-link', params: MagicLinkSignInParams(
    email: 'alice@example.com',
    token: 'valid-token',
  ));

  expect(user.email, 'alice@example.com');
});
Copyright © 2026