diff --git a/AGENTS.md b/AGENTS.md index afa4a8081..cff505863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/examples/passkeys/pubspec.yaml b/examples/passkeys/pubspec.yaml index 53622da39..49607f0e6 100644 --- a/examples/passkeys/pubspec.yaml +++ b/examples/passkeys/pubspec.yaml @@ -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: diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index efa94b4dc..615e77dcf 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -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) @@ -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. +### 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. + ### [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). diff --git a/packages/supabase_flutter/lib/src/supabase_restore_credential.dart b/packages/supabase_flutter/lib/src/supabase_restore_credential.dart new file mode 100644 index 000000000..97d10342a --- /dev/null +++ b/packages/supabase_flutter/lib/src/supabase_restore_credential.dart @@ -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 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 _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 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(), + ); + } +} diff --git a/packages/supabase_flutter/lib/supabase_flutter.dart b/packages/supabase_flutter/lib/supabase_flutter.dart index 7022a540b..661c4a95b 100644 --- a/packages/supabase_flutter/lib/supabase_flutter.dart +++ b/packages/supabase_flutter/lib/supabase_flutter.dart @@ -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'; diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index 9bd0db986..6c7a2ac4b 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -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 diff --git a/packages/supabase_flutter/test/restore_credential_test.dart b/packages/supabase_flutter/test/restore_credential_test.dart new file mode 100644 index 000000000..7dd0629dd --- /dev/null +++ b/packages/supabase_flutter/test/restore_credential_test.dart @@ -0,0 +1,328 @@ +// ignore_for_file: experimental_member_use + +import 'package:flutter_test/flutter_test.dart'; +import 'package:passkeys_platform_interface/passkeys_platform_interface.dart'; +import 'package:passkeys_platform_interface/types/types.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:supabase_test/supabase_test.dart'; + +const _challengeId = 'f9e16464-9ce8-4eb4-b3b3-456a8e95dfa9'; +const _passkeyId = '4b52e9e2-7c1b-44e5-8b5b-d4769ce06f58'; + +const _registrationOptionsPath = '/passkeys/registration/options'; +const _registrationVerifyPath = '/passkeys/registration/verify'; +const _authenticationOptionsPath = '/passkeys/authentication/options'; +const _authenticationVerifyPath = '/passkeys/authentication/verify'; +const _passkeyPath = '/passkeys/$_passkeyId'; + +Map _registrationOptions({bool withUserName = true}) => { + 'challenge_id': _challengeId, + 'options': { + 'challenge': 'Y2hhbGxlbmdl', + 'rp': {'id': 'example.com', 'name': 'Example'}, + 'user': { + 'id': 'dXNlcg', + if (withUserName) 'name': 'jane@example.com', + if (withUserName) 'displayName': 'jane@example.com', + }, + 'pubKeyCredParams': [ + {'type': 'public-key', 'alg': -7}, + ], + }, + 'expires_at': 1735689900, +}; + +const _authenticationOptions = { + 'challenge_id': _challengeId, + 'options': { + 'challenge': 'Y2hhbGxlbmdl', + 'rpId': 'example.com', + 'userVerification': 'preferred', + }, + 'expires_at': 1735689900, +}; + +Map _passkeyJson(String friendlyName) => { + 'id': _passkeyId, + 'friendly_name': friendlyName, + 'created_at': '2025-01-01T00:00:00Z', +}; + +class _FakeRestoreCredential implements RestoreCredentialInterface { + _FakeRestoreCredential({this.error}); + + static const registrationResponse = RegisterResponseType( + id: 'credential-id', + rawId: 'credential-id', + clientDataJSON: 'data', + attestationObject: 'data', + transports: ['internal'], + ); + static const authenticationResponse = AuthenticateResponseType( + id: 'credential-id', + rawId: 'credential-id', + clientDataJSON: 'data', + authenticatorData: 'data', + signature: 'signature', + userHandle: testUserId, + ); + + final Object? error; + RegisterRequestType? createRequest; + bool? createIsCloudBackupEnabled; + AuthenticateRequestType? getRequest; + int clearCalls = 0; + + @override + Future createRestoreCredential( + RegisterRequestType request, { + bool isCloudBackupEnabled = true, + }) async { + createRequest = request; + createIsCloudBackupEnabled = isCloudBackupEnabled; + if (error != null) throw error!; + return registrationResponse; + } + + @override + Future getRestoreCredential( + AuthenticateRequestType request, + ) async { + getRequest = request; + if (error != null) throw error!; + return authenticationResponse; + } + + @override + Future clearRestoreCredential() async { + clearCalls++; + } +} + +void main() { + late MockSupabaseHttpClient httpClient; + late AuthClient client; + + setUp(() { + httpClient = MockSupabaseHttpClient() + ..stub( + _registrationOptions(), + method: HttpMethod.post.value, + path: _registrationOptionsPath, + ) + ..stub( + _passkeyJson('Google Password Manager'), + method: HttpMethod.post.value, + path: _registrationVerifyPath, + ) + ..stubHandler( + (request) { + final body = request.jsonBody as Map; + return jsonResponse(_passkeyJson(body['friendly_name'] as String)); + }, + method: HttpMethod.patch.value, + path: _passkeyPath, + ) + ..stub( + _authenticationOptions, + method: HttpMethod.post.value, + path: _authenticationOptionsPath, + ) + ..stub( + testSessionResponseJson( + accessToken: unsignedTestJwt({ + 'sub': testUserId, + 'role': 'authenticated', + }), + ), + method: HttpMethod.post.value, + path: _authenticationVerifyPath, + ); + client = AuthClient( + url: 'http://localhost:9999', + httpClient: httpClient, + autoRefreshToken: false, + asyncStorage: MemoryAuthAsyncStorage(), + ); + }); + + tearDown(() => client.dispose()); + + Iterable requestedPaths() => + httpClient.requests.map((request) => request.url.path); + + group('createRestoreKey', () { + setUp(() => signInTestUser(client)); + + test('runs the registration ceremony and names the key', () async { + final restore = _FakeRestoreCredential(); + + final passkey = await client.createRestoreKey(restore); + + expect(requestedPaths(), [ + _registrationOptionsPath, + _registrationVerifyPath, + _passkeyPath, + ]); + final request = restore.createRequest!; + expect(request.challenge, 'Y2hhbGxlbmdl'); + expect(request.relyingParty.id, 'example.com'); + expect(request.relyingParty.name, 'Example'); + expect(request.user.name, 'jane@example.com'); + expect(restore.createIsCloudBackupEnabled, isTrue); + + final verify = httpClient.requestsTo(_registrationVerifyPath).single; + expect(verify.jsonBody, { + 'challenge_id': _challengeId, + 'credential': _FakeRestoreCredential.registrationResponse.toJson(), + }); + + final rename = httpClient.requestsTo(_passkeyPath).single; + expect(rename.jsonBody, {'friendly_name': 'Android restore key'}); + expect(passkey.id, _passkeyId); + expect(passkey.friendlyName, 'Android restore key'); + }); + + test('uses a custom friendly name for the key and account label', () async { + httpClient.stub( + _registrationOptions(withUserName: false), + method: HttpMethod.post.value, + path: _registrationOptionsPath, + ); + final restore = _FakeRestoreCredential(); + + final passkey = await client.createRestoreKey( + restore, + friendlyName: 'Pixel restore key', + ); + + expect(restore.createRequest?.user.name, 'Pixel restore key'); + expect(restore.createRequest?.user.displayName, 'Pixel restore key'); + expect(httpClient.requestsTo(_passkeyPath).single.jsonBody, { + 'friendly_name': 'Pixel restore key', + }); + expect(passkey.friendlyName, 'Pixel restore key'); + }); + + test('rethrows platform errors without registering anything', () async { + final restore = _FakeRestoreCredential( + error: StateError('backup unavailable'), + ); + + await expectLater( + client.createRestoreKey(restore), + throwsA(isA()), + ); + + expect(requestedPaths(), [_registrationOptionsPath]); + }); + + test('removes the device key again when the server rejects it', () async { + httpClient.stub( + { + 'code': 400, + 'error_code': 'validation_failed', + 'msg': 'Invalid credential', + }, + method: HttpMethod.post.value, + path: _registrationVerifyPath, + statusCode: 400, + ); + final restore = _FakeRestoreCredential(); + + await expectLater( + client.createRestoreKey(restore), + throwsA(isA()), + ); + + expect(restore.clearCalls, 1); + expect(requestedPaths(), [ + _registrationOptionsPath, + _registrationVerifyPath, + ]); + }); + + test('keeps the registered key when only the rename fails', () async { + httpClient.stub( + { + 'code': 500, + 'error_code': 'unexpected_failure', + 'msg': 'Database error', + }, + method: HttpMethod.patch.value, + path: _passkeyPath, + statusCode: 500, + ); + final restore = _FakeRestoreCredential(); + + final passkey = await client.createRestoreKey(restore); + + expect(passkey.id, _passkeyId); + expect(passkey.friendlyName, 'Google Password Manager'); + expect(restore.clearCalls, 0); + }); + + test('forwards a local-only restore key request', () async { + final restore = _FakeRestoreCredential(); + + await client.createRestoreKey(restore, isCloudBackupEnabled: false); + + expect(restore.createIsCloudBackupEnabled, isFalse); + }); + }); + + group('signInWithRestoreKey', () { + test('runs the authentication ceremony and signs in', () async { + final signedIn = client.onAuthStateChange.firstWhere( + (state) => state.event == AuthChangeEvent.signedIn, + ); + final restore = _FakeRestoreCredential(); + + final response = await client.signInWithRestoreKey( + restore, + captchaToken: 'captcha-token', + ); + + expect(requestedPaths(), [ + _authenticationOptionsPath, + _authenticationVerifyPath, + ]); + expect( + httpClient.requestsTo(_authenticationOptionsPath).single.jsonBody, + { + 'gotrue_meta_security': {'captcha_token': 'captcha-token'}, + }, + ); + final request = restore.getRequest!; + expect(request.challenge, 'Y2hhbGxlbmdl'); + expect(request.relyingPartyId, 'example.com'); + expect(request.userVerification, 'preferred'); + + final verify = httpClient.requestsTo(_authenticationVerifyPath).single; + expect(verify.jsonBody, { + 'challenge_id': _challengeId, + 'credential': _FakeRestoreCredential.authenticationResponse.toJson(), + }); + + expect(response.session, isNotNull); + expect(client.currentSession?.accessToken, response.session?.accessToken); + expect(client.currentUser?.id, testUserId); + expect( + (await signedIn).session?.accessToken, + response.session?.accessToken, + ); + }); + + test('rethrows platform errors without verifying', () async { + final restore = _FakeRestoreCredential(error: StateError('no key')); + + await expectLater( + client.signInWithRestoreKey(restore), + throwsA(isA()), + ); + + expect(requestedPaths(), [_authenticationOptionsPath]); + expect(client.currentSession, isNull); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index a5c0a34ac..0c09fa65b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,58 +675,58 @@ packages: dependency: transitive description: name: passkeys - sha256: a8feedc7e575d88bc705fd1c84eacbf65367e3e2e05183b4bbcdb95acbfa4c54 + sha256: b3230c542246d7f90093e8ce00cf8552006cf5e54dbc1cb9b593f12f85d20705 url: "https://pub.dev" source: hosted - version: "2.21.1" + version: "2.23.1" passkeys_android: dependency: transitive description: name: passkeys_android - sha256: a3e36f0114400ae07376f0327c0600189bd37af2535443ef45f83444c6d94569 + sha256: "92e7607612a6cf6fc8c6590384ffc25f98e9cad1154b17c84d6ccf251ba25c31" url: "https://pub.dev" source: hosted - version: "2.12.2" + version: "2.14.1" passkeys_darwin: dependency: transitive description: name: passkeys_darwin - sha256: "07944baf2f18f21bb85cb7f8f50c4b3318a948bdf32fe6c8e737cfc41fbfb639" + sha256: ceb245f1c98fa25119fd2795ebe8dc39dd7a6b443de77bf58fde9f5ff76237b9 url: "https://pub.dev" source: hosted - version: "0.4.1+1" + version: "0.4.5" passkeys_doctor: dependency: transitive description: name: passkeys_doctor - sha256: d5c797c1a8307fbfb43bedb35845400c1513d215314dff4116b280ca83b0b4ff + sha256: "44eefcc28603cdeb052e7a6b5fd8642f47e54bb5450ffbd7da3dc0e2a728bb7b" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.6.2" passkeys_platform_interface: dependency: transitive description: name: passkeys_platform_interface - sha256: "9610bd136b3382500390912ddd8517ee99505228b1af7b507b9c907f7e99a47d" + sha256: "5fca044a6f1a3c5abff645ca5a5417b613c130d1c4e08513ba9182c7f4834619" url: "https://pub.dev" source: hosted - version: "2.8.0" + version: "2.10.0" passkeys_web: dependency: transitive description: name: passkeys_web - sha256: "89b2a7124d24560ae8eb5eaaa98e1079e5eb488dfaeeb0f19cea032ff2c42d26" + sha256: "5a02f256914eeb5d06d1b5a8e552c6a2837a7364d7591c14b06a992fcc4da051" url: "https://pub.dev" source: hosted - version: "2.9.2" + version: "2.10.1" passkeys_windows: dependency: transitive description: name: passkeys_windows - sha256: "03f0525ed3acc19f5d8cd6d0251284ac2e6ebd5e1af53aae90e31a3afdddac40" + sha256: "43d116e42324788e1cddc1ba6e98a32a28ccc47f68508c4cd4a19e6309daa127" url: "https://pub.dev" source: hosted - version: "0.1.2+1" + version: "0.1.5" path: dependency: transitive description: @@ -1108,14 +1108,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - ua_client_hints: - dependency: transitive - description: - name: ua_client_hints - sha256: e6e302bb7c21d6a90023db61e0ec413f0810ce341bb7e2ab447ff5d61ec3d87e - url: "https://pub.dev" - source: hosted - version: "1.7.0" url_launcher: dependency: transitive description: diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index aad444f3a..7d075b69a 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -425,9 +425,11 @@ features: status: implemented symbols: - AuthClientPasskey.registerPasskey + - AuthClientRestoreCredential.createRestoreKey - AuthPasskeyApi.startRegistration - AuthPasskeyApi.verifyRegistration supporting_symbols: + - AuthClientRestoreCredential - PasskeyRegistrationOptionsResponse - PasskeyRegistrationOptionsResponse.PasskeyRegistrationOptionsResponse - PasskeyRegistrationOptionsResponse.challengeId @@ -438,6 +440,7 @@ features: status: implemented symbols: - AuthClientPasskey.signInWithPasskey + - AuthClientRestoreCredential.signInWithRestoreKey - AuthPasskeyApi.startAuthentication - AuthPasskeyApi.verifyAuthentication supporting_symbols: