Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ dart test test/specific_test.dart
dart test test/specific_test.dart -n "test name pattern"
```

### Writing Tests

Build tests on the `supabase_test` package instead of writing mocks by hand:

- Answer HTTP calls with `MockSupabaseHttpClient` and its `stub`, `stubHandler`, `stubError` and `stubTable`/`stubRpc`/`stubStorage*` helpers. Do not subclass `BaseClient` in a test. Match methods with `HttpMethod.post.value` and friends rather than raw strings.
- Assert on what was sent with `requests` and `requestsTo(path, method:)`.
- Put a client into a signed-in state with `signInTestUser`, and build server payloads with `testUserJson`, `testSessionResponseJson` and `unsignedTestJwt`. Do not hand-assemble JWTs or session JSON.
- Construct clients with `testSupabaseClient` where a whole `SupabaseClient` is needed.

Fakes for things `supabase_test` does not cover, such as a platform plugin interface, are written in the test file itself.

### Package Management

```bash
Expand Down
2 changes: 1 addition & 1 deletion examples/passkeys/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ resolution: workspace
dependencies:
flutter:
sdk: flutter
passkeys: ^2.21.1
passkeys: ^2.23.1
supabase_flutter: ^3.0.0-dev.3

dev_dependencies:
Expand Down
5 changes: 5 additions & 0 deletions packages/supabase_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ final supabase = Supabase.instance.client;
* [Native Google sign in](#native-google-sign-in)
* [OAuth login](#oauth-login)
* [Passkeys](#passkeys)
* [Android Restore Credentials](#android-restore-credentials)
* [Database](#database)
* [Realtime](#realtime)
* [Postgres Changes](#postgres-changes)
Expand Down Expand Up @@ -318,6 +319,10 @@ await supabase.auth.passkey.delete(passkeyId: passkeys.first.id);

The platform ceremony is handled by whichever plugin you add. Refer to your plugin's documentation, for example the [`passkeys` package documentation](https://pub.dev/packages/passkeys), for its platform requirements, setup, and how to handle ceremony failures such as the user cancelling.

### <a id="android-restore-credentials"></a>Android Restore Credentials

Android's [Restore Credentials](https://developer.android.com/identity/sign-in/restore-credentials) restore keys are passkeys, so they use the same BETA passkey feature and the same authenticator. The [`passkeys`](https://pub.dev/packages/passkeys) plugin's `PasskeyAuthenticator` implements the `RestoreCredentialInterface` these methods expect (since `passkeys` `2.23.1`). Call `supabase.auth.createRestoreKey(authenticator)` after a non-anonymous sign-in and `supabase.auth.signInWithRestoreKey(authenticator)` on the first launch on a new device. On sign-out, delete the server passkey and call `clearRestoreCredential()` on the authenticator. See the API documentation of `AuthClientRestoreCredential` for the details.

### <a id="database"></a>[Database](https://supabase.com/docs/guides/database)

Database methods are used to perform basic CRUD operations using the Supabase REST API. Full list of supported operators can be found [here](https://supabase.com/docs/reference/dart/select).
Expand Down
140 changes: 140 additions & 0 deletions packages/supabase_flutter/lib/src/supabase_restore_credential.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// This file intentionally builds on supabase_auth's experimental passkey API.
// ignore_for_file: experimental_member_use

import 'package:meta/meta.dart';
import 'package:passkeys_platform_interface/passkeys_platform_interface.dart';
import 'package:supabase_flutter/src/logger.dart';
import 'package:supabase_flutter/src/passkey/passkey_options_mapper.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

/// Android Restore Credentials ("zero-tap sign-in") on top of Supabase
/// passkeys.
///
/// A restore key is a passkey that Android creates silently, backs up together
/// with the app data, and makes available on the user's next device. Because
/// the server side is the same as for passkeys, Supabase Auth stores and
/// verifies restore keys as regular passkeys. Signing in with one on the new
/// device yields a brand new session, independent of the refresh token chain
/// of the old device, so it is safe to use as the credential restored during
/// device setup.
///
/// Passkeys are a BETA feature and must be enabled for your project in the
/// Supabase Dashboard under Authentication > Configuration > Passkeys. Android
/// also requires Digital Asset Links for the relying party ID, exactly as for
/// passkeys.
///
/// The platform calls are delegated to the [RestoreCredentialInterface] you
/// pass in. The [`passkeys`](https://pub.dev/packages/passkeys) plugin's
/// `PasskeyAuthenticator` implements it since `passkeys` `2.23.1`, so the same
/// object serves [AuthClientPasskey.registerPasskey] and these methods.
///
/// Restore Credentials only exist on Android. Guard the calls with
/// `defaultTargetPlatform == TargetPlatform.android`.
///
/// Methods rethrow whatever the [RestoreCredentialInterface] throws when the
/// platform call fails, for example when there is no restore key on the device,
/// and throw [AuthException] when the Supabase server rejects the credential.
@experimental
extension AuthClientRestoreCredential on AuthClient {
/// Creates a restore key for the signed in user and registers it as a
/// passkey.
///
/// Call it right after a non-anonymous user signs in, and on a later launch
/// if the user is signed in and no restore key exists yet. Android keeps one
/// restore key per app, so remember the returned [Passkey.id] and delete the
/// previous key with [AuthPasskeyApi.delete] before creating a new one.
///
/// When the user signs out, delete the key on both sides: the server passkey
/// with [AuthPasskeyApi.delete] and the key on the device with
/// [RestoreCredentialInterface.clearRestoreCredential]. Android does not
/// remove the device key on its own, so without the second step the user is
/// signed in again on the next launch.
///
/// If the server rejects the created credential, the key is removed from the
/// device again before the error is rethrown. If only the rename to
/// [friendlyName] fails, the registered passkey is returned under the name
/// the server gave it and the failure is logged.
///
/// [friendlyName] becomes the passkey's friendly name so restore keys can be
/// told apart from the passkeys the user created, for example to hide them
/// from a passkey management screen. It is also used as the account label
/// when the server does not provide a `user.name` in the options, see
/// [AuthPasskeyApi.startRegistration].
///
/// [isCloudBackupEnabled] backs the restore key up to the cloud when the
/// device has end-to-end encrypted backup and stores it locally otherwise.
/// Pass `false` to always keep it local.
///
/// Requires a signed in (non-anonymous) user.
Future<Passkey> createRestoreKey(
RestoreCredentialInterface restoreCredential, {
String friendlyName = 'Android restore key',
bool isCloudBackupEnabled = true,
}) async {
final registration = await passkey.startRegistration(
friendlyName: friendlyName,
);
final response = await restoreCredential.createRestoreCredential(
passkeyRegisterRequestFromOptions(registration.options),
isCloudBackupEnabled: isCloudBackupEnabled,
);
final Passkey registered;
try {
registered = await passkey.verifyRegistration(
challengeId: registration.challengeId,
credential: response.toJson(),
);
} catch (_) {
await _clearRestoreCredentialQuietly(restoreCredential);
rethrow;
}
try {
return await passkey.update(
passkeyId: registered.id,
friendlyName: friendlyName,
);
} catch (error, stackTrace) {
flutterLogger.warning(
'Restore key ${registered.id} was registered but could not be renamed',
error,
stackTrace,
);
return registered;
}
}

Future<void> _clearRestoreCredentialQuietly(
RestoreCredentialInterface restoreCredential,
) async {
try {
await restoreCredential.clearRestoreCredential();
} catch (error, stackTrace) {
flutterLogger.warning(
'Could not remove the rejected restore key from the device',
error,
stackTrace,
);
}
}

/// Signs the user in with the restore key on the device.
///
/// Call it on the first launch after the app has been restored on a new
/// device. Does not require an existing session. On success the session is
/// persisted and an [AuthChangeEvent.signedIn] event is fired.
Future<AuthResponse> signInWithRestoreKey(
RestoreCredentialInterface restoreCredential, {
String? captchaToken,
}) async {
final authentication = await passkey.startAuthentication(
captchaToken: captchaToken,
);
final response = await restoreCredential.getRestoreCredential(
passkeyAuthenticateRequestFromOptions(authentication.options),
);
return passkey.verifyAuthentication(
challengeId: authentication.challengeId,
credential: response.toJson(),
);
}
}
1 change: 1 addition & 0 deletions packages/supabase_flutter/lib/supabase_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export 'src/shared_preferences_auth_async_storage.dart';
export 'src/supabase.dart';
export 'src/supabase_auth.dart' hide SupabaseAuth;
export 'src/supabase_passkey.dart';
export 'src/supabase_restore_credential.dart';
2 changes: 1 addition & 1 deletion packages/supabase_flutter/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies:
sdk: flutter
http: ^1.6.0
meta: ^1.16.0
passkeys_platform_interface: ^2.8.0
passkeys_platform_interface: ^2.10.0
supabase: 3.0.0-dev.3
supabase_common: 3.0.0-dev.2
url_launcher: ^6.3.2
Expand Down
Loading