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
nullon credential failure, don't throw for an expected "wrong password". - Throw for infrastructure errors: network failures, malformed config, CSRF mismatches.
signOutmust never throw: a failed server-side revocation shouldn't block local cleanup.- Populate token fields when available: leaving
accessTokenasnulldisablesgetAccessToken()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');
});