diff --git a/infra-gen2/backends/auth/webauthn/.gitignore b/infra-gen2/backends/auth/webauthn/.gitignore new file mode 100644 index 00000000000..504c0a0cd12 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/.gitignore @@ -0,0 +1,2 @@ +amplify_outputs.dart +.amplify diff --git a/infra-gen2/backends/auth/webauthn/amplify/auth/pre-sign-up-handler.ts b/infra-gen2/backends/auth/webauthn/amplify/auth/pre-sign-up-handler.ts new file mode 100644 index 00000000000..ddf9e2d8912 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/amplify/auth/pre-sign-up-handler.ts @@ -0,0 +1,4 @@ +import { PreSignUpTriggerHandler } from "aws-lambda"; +import { preSignUpTriggerHandler } from "infra-common"; + +export const handler: PreSignUpTriggerHandler = preSignUpTriggerHandler; diff --git a/infra-gen2/backends/auth/webauthn/amplify/auth/resource.ts b/infra-gen2/backends/auth/webauthn/amplify/auth/resource.ts new file mode 100644 index 00000000000..cb85b5d7347 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/amplify/auth/resource.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineAuth, defineFunction } from "@aws-amplify/backend"; + +export const preSignUp = defineFunction({ + name: "pre-sign-up", + entry: "./pre-sign-up-handler.ts", +}); + +export const auth = defineAuth({ + loginWith: { + email: { otpLogin: true }, + phone: { otpLogin: true }, + webAuthn: true, // relyingPartyId auto-resolves to localhost in sandbox + }, + passwordlessOptions: { + preferredChallenge: "WEB_AUTHN" + }, + userAttributes: { + phoneNumber: { + required: false + } + }, + triggers: { + preSignUp, + }, +}); diff --git a/infra-gen2/backends/auth/webauthn/amplify/backend.ts b/infra-gen2/backends/auth/webauthn/amplify/backend.ts new file mode 100644 index 00000000000..4ca385d165b --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/amplify/backend.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineBackend } from "@aws-amplify/backend"; +import { auth } from "./auth/resource"; + +defineBackend({ + auth, +}); diff --git a/infra-gen2/backends/auth/webauthn/amplify/package.json b/infra-gen2/backends/auth/webauthn/amplify/package.json new file mode 100644 index 00000000000..5ffd9800b97 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/amplify/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/infra-gen2/backends/auth/webauthn/amplify/tsconfig.json b/infra-gen2/backends/auth/webauthn/amplify/tsconfig.json new file mode 100644 index 00000000000..ff3843f49e5 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/amplify/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "paths": { + "$amplify/*": [ + "../.amplify/generated/*" + ] + } + } +} diff --git a/infra-gen2/backends/auth/webauthn/package.json b/infra-gen2/backends/auth/webauthn/package.json new file mode 100644 index 00000000000..9684eb89e83 --- /dev/null +++ b/infra-gen2/backends/auth/webauthn/package.json @@ -0,0 +1,5 @@ +{ + "name": "webauthn", + "version": "1.0.0", + "main": "index.js" +} diff --git a/infra-gen2/package-lock.json b/infra-gen2/package-lock.json index 85ffc8473b0..7adfd1a58bd 100644 --- a/infra-gen2/package-lock.json +++ b/infra-gen2/package-lock.json @@ -76,6 +76,9 @@ "backends/auth/username-login-mfa": { "version": "1.0.0" }, + "backends/auth/webauthn": { + "version": "1.0.0" + }, "backends/kinesis/main": { "name": "kinesis-main", "version": "1.0.0" @@ -41874,6 +41877,10 @@ "node": ">= 8" } }, + "node_modules/webauthn": { + "resolved": "backends/auth/webauthn", + "link": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/infra-gen2/tool/deploy_gen2.dart b/infra-gen2/tool/deploy_gen2.dart index 8982aea1492..644add29127 100644 --- a/infra-gen2/tool/deploy_gen2.dart +++ b/infra-gen2/tool/deploy_gen2.dart @@ -93,6 +93,11 @@ const List infraConfig = [ identifier: 'user-login-mfa', pathToSource: 'infra-gen2/backends/auth/username-login-mfa', ), + AmplifyBackend( + name: 'webauthn', + identifier: 'webauthn', + pathToSource: 'infra-gen2/backends/auth/webauthn', + ), ], ), AmplifyBackendGroup( diff --git a/packages/amplify_core/lib/src/category/amplify_auth_category.dart b/packages/amplify_core/lib/src/category/amplify_auth_category.dart index 00a9065dd3d..f49f802246f 100644 --- a/packages/amplify_core/lib/src/category/amplify_auth_category.dart +++ b/packages/amplify_core/lib/src/category/amplify_auth_category.dart @@ -1466,4 +1466,55 @@ class AuthCategory extends AmplifyCategory { AuthCategoryMethod.deleteUser, () => defaultPlugin.deleteUser(), ); + + /// {@template amplify_core.amplify_auth_category.associate_webauthn_credential} + /// Registers a new passkey/WebAuthn credential on the user's account. + /// + /// This orchestrates the full registration flow: requests creation options + /// from the server, triggers the platform WebAuthn ceremony, and sends + /// the credential back to the server. + /// + /// Requires an authenticated user session. + /// {@endtemplate} + Future associateWebAuthnCredential() => identifyCall( + AuthCategoryMethod.associateWebAuthnCredential, + () => defaultPlugin.associateWebAuthnCredential(), + ); + + /// {@template amplify_core.amplify_auth_category.list_webauthn_credentials} + /// Lists all passkey/WebAuthn credentials registered on the user's account. + /// + /// Returns a list of [AuthWebAuthnCredential] objects representing each + /// registered credential. + /// + /// Requires an authenticated user session. + /// {@endtemplate} + Future> listWebAuthnCredentials() => + identifyCall( + AuthCategoryMethod.listWebAuthnCredentials, + () => defaultPlugin.listWebAuthnCredentials(), + ); + + /// {@template amplify_core.amplify_auth_category.delete_webauthn_credential} + /// Deletes a specific passkey/WebAuthn credential from the user's account. + /// + /// The [credentialId] parameter identifies which credential to delete. + /// + /// Requires an authenticated user session. + /// {@endtemplate} + Future deleteWebAuthnCredential(String credentialId) => identifyCall( + AuthCategoryMethod.deleteWebAuthnCredential, + () => defaultPlugin.deleteWebAuthnCredential(credentialId), + ); + + /// {@template amplify_core.amplify_auth_category.is_passkey_supported} + /// Checks if passkey/WebAuthn is supported on the current platform. + /// + /// Returns `true` if the platform supports WebAuthn credentials, + /// `false` otherwise. + /// {@endtemplate} + Future isPasskeySupported() => identifyCall( + AuthCategoryMethod.isPasskeySupported, + () => defaultPlugin.isPasskeySupported(), + ); } diff --git a/packages/amplify_core/lib/src/config/amplify_outputs/auth/auth_outputs.dart b/packages/amplify_core/lib/src/config/amplify_outputs/auth/auth_outputs.dart index 4119c8baacb..3a75020ce1e 100644 --- a/packages/amplify_core/lib/src/config/amplify_outputs/auth/auth_outputs.dart +++ b/packages/amplify_core/lib/src/config/amplify_outputs/auth/auth_outputs.dart @@ -5,6 +5,8 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_core/src/config/amplify_outputs/auth/mfa.dart'; import 'package:amplify_core/src/config/amplify_outputs/auth/oauth_outputs.dart'; import 'package:amplify_core/src/config/amplify_outputs/auth/password_policy.dart'; +import 'package:amplify_core/src/config/amplify_outputs/auth/passwordless_outputs.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; part 'auth_outputs.g.dart'; @@ -31,10 +33,34 @@ class AuthOutputs this.unauthenticatedIdentitiesEnabled = true, this.mfaConfiguration, this.mfaMethods, + this.passwordless, }); - factory AuthOutputs.fromJson(Map json) => - _$AuthOutputsFromJson(json); + factory AuthOutputs.fromJson(Map json) { + final base = _$AuthOutputsFromJson(json); + final passwordlessJson = json['passwordless']; + return AuthOutputs( + awsRegion: base.awsRegion, + userPoolId: base.userPoolId, + userPoolClientId: base.userPoolClientId, + userPoolEndpoint: base.userPoolEndpoint, + appClientSecret: base.appClientSecret, + identityPoolId: base.identityPoolId, + passwordPolicy: base.passwordPolicy, + oauth: base.oauth, + standardRequiredAttributes: base.standardRequiredAttributes, + usernameAttributes: base.usernameAttributes, + userVerificationTypes: base.userVerificationTypes, + unauthenticatedIdentitiesEnabled: base.unauthenticatedIdentitiesEnabled, + mfaConfiguration: base.mfaConfiguration, + mfaMethods: base.mfaMethods, + passwordless: passwordlessJson == null + ? null + : PasswordlessOutputs.fromJson( + passwordlessJson as Map, + ), + ); + } /// The AWS region of Amazon Cognito resources. final String awsRegion; @@ -83,6 +109,10 @@ class AuthOutputs /// {@macro amplify_core.amplify_outputs.maf_method} final List? mfaMethods; + /// Passwordless authentication configuration. + @JsonKey(includeFromJson: false, includeToJson: false) + final PasswordlessOutputs? passwordless; + @override List get props => [ awsRegion, diff --git a/packages/amplify_core/lib/src/config/amplify_outputs/auth/passwordless_outputs.dart b/packages/amplify_core/lib/src/config/amplify_outputs/auth/passwordless_outputs.dart new file mode 100644 index 00000000000..e4c75b28daa --- /dev/null +++ b/packages/amplify_core/lib/src/config/amplify_outputs/auth/passwordless_outputs.dart @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_core/amplify_core.dart'; + +/// Passwordless authentication configuration from Amplify outputs. +class PasswordlessOutputs { + const PasswordlessOutputs({ + this.emailOtpEnabled = false, + this.smsOtpEnabled = false, + this.webAuthnEnabled = false, + this.preferredChallenge, + }); + + factory PasswordlessOutputs.fromJson(Map json) { + return PasswordlessOutputs( + emailOtpEnabled: json['email_otp_enabled'] as bool? ?? false, + smsOtpEnabled: json['sms_otp_enabled'] as bool? ?? false, + webAuthnEnabled: json['web_authn'] != null, + preferredChallenge: _parseChallenge( + json['preferred_challenge'] as String?, + ), + ); + } + + static AuthFactorType? _parseChallenge(String? value) { + switch (value) { + case 'WEB_AUTHN': + return AuthFactorType.webAuthn; + case 'EMAIL_OTP': + return AuthFactorType.emailOtp; + case 'SMS_OTP': + return AuthFactorType.smsOtp; + case 'PASSWORD': + case 'PASSWORD_SRP': + return AuthFactorType.password; + default: + return null; + } + } + + final bool emailOtpEnabled; + final bool smsOtpEnabled; + final bool webAuthnEnabled; + + /// The preferred first-factor challenge from the backend config. + final AuthFactorType? preferredChallenge; + + /// Returns all enabled passwordless methods. + List get enabledMethods => [ + if (webAuthnEnabled) AuthFactorType.webAuthn, + if (emailOtpEnabled) AuthFactorType.emailOtp, + if (smsOtpEnabled) AuthFactorType.smsOtp, + ]; +} diff --git a/packages/amplify_core/lib/src/http/amplify_category_method.dart b/packages/amplify_core/lib/src/http/amplify_category_method.dart index bbdbec51c12..52c7a5737e5 100644 --- a/packages/amplify_core/lib/src/http/amplify_category_method.dart +++ b/packages/amplify_core/lib/src/http/amplify_category_method.dart @@ -53,7 +53,11 @@ enum AuthCategoryMethod with AmplifyCategoryMethod { getMfaPreference('50'), setUpTotp('51'), verifyTotpSetup('52'), - fetchCurrentDevice('59'); + fetchCurrentDevice('59'), + associateWebAuthnCredential('60'), + listWebAuthnCredentials('61'), + deleteWebAuthnCredential('62'), + isPasskeySupported('63'); const AuthCategoryMethod(this.method); diff --git a/packages/amplify_core/lib/src/plugin/amplify_auth_plugin_interface.dart b/packages/amplify_core/lib/src/plugin/amplify_auth_plugin_interface.dart index ce1ee50ad05..c47b9885d46 100644 --- a/packages/amplify_core/lib/src/plugin/amplify_auth_plugin_interface.dart +++ b/packages/amplify_core/lib/src/plugin/amplify_auth_plugin_interface.dart @@ -200,4 +200,30 @@ abstract class AuthPluginInterface extends AmplifyPluginInterface { Future deleteUser() { throw UnimplementedError('deleteUser() has not been implemented.'); } + + /// {@macro amplify_core.amplify_auth_category.associate_webauthn_credential} + Future associateWebAuthnCredential() { + throw UnimplementedError( + 'associateWebAuthnCredential() has not been implemented.', + ); + } + + /// {@macro amplify_core.amplify_auth_category.list_webauthn_credentials} + Future> listWebAuthnCredentials() { + throw UnimplementedError( + 'listWebAuthnCredentials() has not been implemented.', + ); + } + + /// {@macro amplify_core.amplify_auth_category.delete_webauthn_credential} + Future deleteWebAuthnCredential(String credentialId) { + throw UnimplementedError( + 'deleteWebAuthnCredential() has not been implemented.', + ); + } + + /// {@macro amplify_core.amplify_auth_category.is_passkey_supported} + Future isPasskeySupported() { + throw UnimplementedError('isPasskeySupported() has not been implemented.'); + } } diff --git a/packages/amplify_core/lib/src/types/auth/auth_types.dart b/packages/amplify_core/lib/src/types/auth/auth_types.dart index 0723dfe78b5..cf8e7ac0ab3 100644 --- a/packages/amplify_core/lib/src/types/auth/auth_types.dart +++ b/packages/amplify_core/lib/src/types/auth/auth_types.dart @@ -13,7 +13,13 @@ export '../exception/amplify_exception.dart' UserCancelledException, AuthValidationException, NetworkException, - UnknownException; + UnknownException, + PasskeyException, + PasskeyNotSupportedException, + PasskeyCancelledException, + PasskeyRegistrationFailedException, + PasskeyAssertionFailedException, + PasskeyRpMismatchException; // Attributes export 'attribute/auth_next_update_attribute_step.dart'; export 'attribute/auth_update_attribute_step.dart'; @@ -34,6 +40,8 @@ export 'auth_device.dart'; export 'auth_next_step.dart'; // Auto Sign In export 'auto_sign_in/auto_sign_in_options.dart'; +// Credentials +export 'credential/auth_webauthn_credential.dart'; // Hub export 'hub/auth_hub_event.dart'; // MFA diff --git a/packages/amplify_core/lib/src/types/auth/credential/auth_webauthn_credential.dart b/packages/amplify_core/lib/src/types/auth/credential/auth_webauthn_credential.dart new file mode 100644 index 00000000000..1a33697ad51 --- /dev/null +++ b/packages/amplify_core/lib/src/types/auth/credential/auth_webauthn_credential.dart @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:aws_common/aws_common.dart'; +import 'package:meta/meta.dart'; + +/// {@category Auth} +/// {@template amplify_core.auth_webauthn_credential} +/// Common interface for WebAuthn/passkey credentials registered with an authentication provider. +/// {@endtemplate} +@immutable +abstract class AuthWebAuthnCredential + with AWSSerializable> { + /// {@macro amplify_core.auth_webauthn_credential} + const AuthWebAuthnCredential(); + + /// Unique identifier for this credential. + String get credentialId; + + /// User-assigned friendly name for the credential (e.g., "My iPhone"). + String? get friendlyName; + + /// Relying party identifier (typically the domain). + String get relyingPartyId; + + /// Type of authenticator attachment (e.g., "platform", "cross-platform"). + String? get authenticatorAttachment; + + /// List of transport types supported by the authenticator (e.g., "usb", "nfc", "ble", "internal"). + List? get authenticatorTransports; + + /// Date and time when the credential was created. + DateTime get createdAt; + + /// Converts the instance to a JSON map. + @override + Map toJson(); + + @override + String toString() { + return 'AuthWebAuthnCredential{credentialId=$credentialId, friendlyName=$friendlyName, relyingPartyId=$relyingPartyId, createdAt=$createdAt}'; + } +} diff --git a/packages/amplify_core/lib/src/types/auth/sign_in/auth_factor_type.dart b/packages/amplify_core/lib/src/types/auth/sign_in/auth_factor_type.dart index b8a52a16899..5de24dd4fbe 100644 --- a/packages/amplify_core/lib/src/types/auth/sign_in/auth_factor_type.dart +++ b/packages/amplify_core/lib/src/types/auth/sign_in/auth_factor_type.dart @@ -23,14 +23,11 @@ enum AuthFactorType { /// Sign in using a One Time Password sent to the user's SMS number @JsonValue('SMS_OTP') - smsOtp('SMS_OTP'); - - // TODO(cadivus): Implement Passwordless Authenticator. See: - // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html#amazon-cognito-user-pools-authentication-flow-methods-passkey - // https://docs.amplify.aws/react/build-a-backend/auth/concepts/passwordless/#webauthn-passkey - // /// Sign in with WebAuthn (i.e. PassKey) - // @JsonValue('WEB_AUTHN') - // webAuthn('WEB_AUTHN'); + smsOtp('SMS_OTP'), + + /// Sign in with WebAuthn (i.e. Passkey) + @JsonValue('WEB_AUTHN') + webAuthn('WEB_AUTHN'); const AuthFactorType(this.value); diff --git a/packages/amplify_core/lib/src/types/auth/sign_in/auth_next_sign_in_step.g.dart b/packages/amplify_core/lib/src/types/auth/sign_in/auth_next_sign_in_step.g.dart index 94a43a8856f..1a2350cb8bb 100644 --- a/packages/amplify_core/lib/src/types/auth/sign_in/auth_next_sign_in_step.g.dart +++ b/packages/amplify_core/lib/src/types/auth/sign_in/auth_next_sign_in_step.g.dart @@ -111,4 +111,5 @@ const _$AuthFactorTypeEnumMap = { AuthFactorType.passwordSrp: 'PASSWORD_SRP', AuthFactorType.emailOtp: 'EMAIL_OTP', AuthFactorType.smsOtp: 'SMS_OTP', + AuthFactorType.webAuthn: 'WEB_AUTHN', }; diff --git a/packages/amplify_core/lib/src/types/exception/amplify_exception.dart b/packages/amplify_core/lib/src/types/exception/amplify_exception.dart index b85a0e26ecb..5f5218989e3 100644 --- a/packages/amplify_core/lib/src/types/exception/amplify_exception.dart +++ b/packages/amplify_core/lib/src/types/exception/amplify_exception.dart @@ -17,6 +17,7 @@ part 'auth/session_expired_exception.dart'; part 'auth/signed_out_exception.dart'; part 'auth/user_cancelled_exception.dart'; part 'auth/validation_exception.dart'; +part 'auth/passkey_exception.dart'; part 'network_exception.dart'; part 'push/push_notification_exception.dart'; part 'storage/access_denied_exception.dart'; diff --git a/packages/amplify_core/lib/src/types/exception/auth/passkey_exception.dart b/packages/amplify_core/lib/src/types/exception/auth/passkey_exception.dart new file mode 100644 index 00000000000..abf626cd8e9 --- /dev/null +++ b/packages/amplify_core/lib/src/types/exception/auth/passkey_exception.dart @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +part of '../amplify_exception.dart'; + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_exception} +/// Exception thrown when a passkey/WebAuthn operation fails. +/// {@endtemplate} +class PasskeyException extends AuthException { + /// {@macro amplify_core.auth.passkey_exception} + const PasskeyException( + super.message, { + super.recoverySuggestion, + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyException'; +} + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_not_supported_exception} +/// Exception thrown when the current device or platform does not support +/// passkeys. +/// {@endtemplate} +class PasskeyNotSupportedException extends PasskeyException { + /// {@macro amplify_core.auth.passkey_not_supported_exception} + const PasskeyNotSupportedException( + super.message, { + super.recoverySuggestion = + 'Passkeys require a compatible device and operating system version.', + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyNotSupportedException'; +} + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_cancelled_exception} +/// Exception thrown when the user cancels the passkey ceremony (e.g. biometric +/// prompt). +/// +/// This is distinct from [UserCancelledException], which is for general auth +/// cancellation. +/// {@endtemplate} +class PasskeyCancelledException extends PasskeyException { + /// {@macro amplify_core.auth.passkey_cancelled_exception} + const PasskeyCancelledException( + super.message, { + super.recoverySuggestion = + 'The passkey operation was cancelled. Please try again.', + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyCancelledException'; +} + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_registration_failed_exception} +/// Exception thrown when the platform fails to create a new passkey credential. +/// {@endtemplate} +class PasskeyRegistrationFailedException extends PasskeyException { + /// {@macro amplify_core.auth.passkey_registration_failed_exception} + const PasskeyRegistrationFailedException( + super.message, { + super.recoverySuggestion = + 'Failed to register passkey. Ensure your device supports passkeys and try again.', + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyRegistrationFailedException'; +} + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_assertion_failed_exception} +/// Exception thrown when the platform fails to retrieve or assert a passkey +/// credential during sign-in. +/// {@endtemplate} +class PasskeyAssertionFailedException extends PasskeyException { + /// {@macro amplify_core.auth.passkey_assertion_failed_exception} + const PasskeyAssertionFailedException( + super.message, { + super.recoverySuggestion = + 'Failed to authenticate with passkey. Ensure you have a registered passkey and try again.', + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyAssertionFailedException'; +} + +/// {@category Auth} +/// {@template amplify_core.auth.passkey_rp_mismatch_exception} +/// Exception thrown when the relying party ID does not match the expected +/// domain. +/// {@endtemplate} +class PasskeyRpMismatchException extends PasskeyException { + /// {@macro amplify_core.auth.passkey_rp_mismatch_exception} + const PasskeyRpMismatchException( + super.message, { + super.recoverySuggestion = + 'The relying party ID does not match the application domain. Check your Cognito user pool configuration.', + super.underlyingException, + }); + + @override + String get runtimeTypeName => 'PasskeyRpMismatchException'; +} diff --git a/packages/amplify_core/test/types/auth/auth_factor_type_webauthn_test.dart b/packages/amplify_core/test/types/auth/auth_factor_type_webauthn_test.dart new file mode 100644 index 00000000000..0265c7ce57b --- /dev/null +++ b/packages/amplify_core/test/types/auth/auth_factor_type_webauthn_test.dart @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_core/amplify_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AuthFactorType.webAuthn', () { + test('enum value exists and has correct string value', () { + expect(AuthFactorType.webAuthn, isNotNull); + expect(AuthFactorType.webAuthn.value, 'WEB_AUTHN'); + }); + + test('is included in AuthFactorType.values', () { + expect(AuthFactorType.values, contains(AuthFactorType.webAuthn)); + expect(AuthFactorType.values.length, 5); + }); + + test('has correct JSON annotation for serialization', () { + // Verify the enum value itself serializes correctly through the value field + expect(AuthFactorType.webAuthn.value, 'WEB_AUTHN'); + }); + }); +} diff --git a/packages/amplify_core/test/types/exception/passkey_exception_test.dart b/packages/amplify_core/test/types/exception/passkey_exception_test.dart new file mode 100644 index 00000000000..6230a5cfafe --- /dev/null +++ b/packages/amplify_core/test/types/exception/passkey_exception_test.dart @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_core/amplify_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('PasskeyException', () { + test('extends AuthException', () { + const exception = PasskeyException('test message'); + expect(exception, isA()); + }); + + test('can be constructed with const', () { + const exception = PasskeyException( + 'test message', + recoverySuggestion: 'test recovery', + ); + expect(exception.message, 'test message'); + expect(exception.recoverySuggestion, 'test recovery'); + }); + }); + + group('PasskeyNotSupportedException', () { + test('extends PasskeyException', () { + const exception = PasskeyNotSupportedException('test message'); + expect(exception, isA()); + expect(exception, isA()); + }); + + test('has default recovery suggestion', () { + const exception = PasskeyNotSupportedException('test message'); + expect( + exception.recoverySuggestion, + 'Passkeys require a compatible device and operating system version.', + ); + }); + + test('allows custom recovery suggestion', () { + const exception = PasskeyNotSupportedException( + 'test message', + recoverySuggestion: 'custom suggestion', + ); + expect(exception.recoverySuggestion, 'custom suggestion'); + }); + + test('can be constructed with const', () { + const exception = PasskeyNotSupportedException('test message'); + expect(exception.message, 'test message'); + }); + }); + + group('PasskeyCancelledException', () { + test('extends PasskeyException', () { + const exception = PasskeyCancelledException('test message'); + expect(exception, isA()); + expect(exception, isA()); + }); + + test('has default recovery suggestion', () { + const exception = PasskeyCancelledException('test message'); + expect( + exception.recoverySuggestion, + 'The passkey operation was cancelled. Please try again.', + ); + }); + }); + + group('PasskeyRegistrationFailedException', () { + test('extends PasskeyException', () { + const exception = PasskeyRegistrationFailedException('test message'); + expect(exception, isA()); + expect(exception, isA()); + }); + + test('has default recovery suggestion', () { + const exception = PasskeyRegistrationFailedException('test message'); + expect( + exception.recoverySuggestion, + 'Failed to register passkey. Ensure your device supports passkeys and try again.', + ); + }); + }); + + group('PasskeyAssertionFailedException', () { + test('extends PasskeyException', () { + const exception = PasskeyAssertionFailedException('test message'); + expect(exception, isA()); + expect(exception, isA()); + }); + + test('has default recovery suggestion', () { + const exception = PasskeyAssertionFailedException('test message'); + expect( + exception.recoverySuggestion, + 'Failed to authenticate with passkey. Ensure you have a registered passkey and try again.', + ); + }); + }); + + group('PasskeyRpMismatchException', () { + test('extends PasskeyException', () { + const exception = PasskeyRpMismatchException('test message'); + expect(exception, isA()); + expect(exception, isA()); + }); + + test('has default recovery suggestion', () { + const exception = PasskeyRpMismatchException('test message'); + expect( + exception.recoverySuggestion, + 'The relying party ID does not match the application domain. Check your Cognito user pool configuration.', + ); + }); + }); + + group('exception hierarchy', () { + test('all subtypes can be caught as PasskeyException', () { + const exceptions = [ + PasskeyNotSupportedException('msg'), + PasskeyCancelledException('msg'), + PasskeyRegistrationFailedException('msg'), + PasskeyAssertionFailedException('msg'), + PasskeyRpMismatchException('msg'), + ]; + + for (final exception in exceptions) { + expect(exception, isA()); + } + }); + + test('all subtypes can be caught as AuthException', () { + const exceptions = [ + PasskeyNotSupportedException('msg'), + PasskeyCancelledException('msg'), + PasskeyRegistrationFailedException('msg'), + PasskeyAssertionFailedException('msg'), + PasskeyRpMismatchException('msg'), + ]; + + for (final exception in exceptions) { + expect(exception, isA()); + } + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito/android/build.gradle b/packages/auth/amplify_auth_cognito/android/build.gradle index e4074a4e1bc..8591dd9cbe9 100644 --- a/packages/auth/amplify_auth_cognito/android/build.gradle +++ b/packages/auth/amplify_auth_cognito/android/build.gradle @@ -58,6 +58,8 @@ android { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' implementation 'androidx.browser:browser:1.9.0' + implementation 'androidx.credentials:credentials:1.3.0' + implementation 'androidx.credentials:credentials-play-services-auth:1.3.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito.kotlin:mockito-kotlin:6.2.1' diff --git a/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPlugin.kt b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPlugin.kt index 493c4b95eb0..0f86c6e69d1 100644 --- a/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPlugin.kt +++ b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPlugin.kt @@ -74,6 +74,11 @@ open class AmplifyAuthCognitoPlugin : */ private var nativePlugin: NativeAuthPlugin? = null + /** + * The WebAuthn bridge for passkey operations. + */ + private var webAuthnBridge: WebAuthnBridgeImpl? = null + /** * The initial route parameters used to launch the main activity, which can happen when using * non-Chrome browsers or when a Hosted UI redirect occurs while the app is closed. They are @@ -120,6 +125,11 @@ open class AmplifyAuthCognitoPlugin : binding.binaryMessenger, this, ) + webAuthnBridge = WebAuthnBridgeImpl( + context = binding.applicationContext, + activityProvider = { mainActivity } + ) + WebAuthnBridgeApi.setUp(binding.binaryMessenger, webAuthnBridge) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -131,6 +141,9 @@ open class AmplifyAuthCognitoPlugin : binding.binaryMessenger, null, ) + webAuthnBridge?.dispose() + webAuthnBridge = null + WebAuthnBridgeApi.setUp(binding.binaryMessenger, null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { diff --git a/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/WebAuthnBridgeImpl.kt b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/WebAuthnBridgeImpl.kt new file mode 100644 index 00000000000..aab58f2afdf --- /dev/null +++ b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/WebAuthnBridgeImpl.kt @@ -0,0 +1,170 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.amazonaws.amplify.amplify_auth_cognito + +import android.app.Activity +import android.content.Context +import android.os.Build +import androidx.credentials.CredentialManager +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.json.JSONObject + +/** + * Android implementation of [WebAuthnBridgeApi] using [CredentialManager]. + * + * Uses `androidx.credentials` to perform passkey creation and assertion ceremonies + * on Android API 28+ (with Play Services fallback for API 28-33). + */ +class WebAuthnBridgeImpl( + private val context: Context, + private val activityProvider: () -> Activity? +) : WebAuthnBridgeApi { + + private val credentialManager = CredentialManager.create(context) + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + override fun isPasskeySupported(callback: (Result) -> Unit) { + // API 28+ is required for CredentialManager (Play Services fallback handles 28-33). + // CredentialManager.create() succeeds in the constructor, so if we reach here + // the library is available. + try { + val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P // API 28 + callback(Result.success(supported)) + } catch (e: Exception) { + callback(Result.success(false)) + } + } + + override fun createCredential(optionsJson: String, callback: (Result) -> Unit) { + val activity = activityProvider() + if (activity == null) { + callback( + Result.failure( + FlutterError("notSupported", "No activity available", null) + ) + ) + return + } + + val request = CreatePublicKeyCredentialRequest(requestJson = optionsJson) + + scope.launch { + try { + val result = credentialManager.createCredential(activity, request) + val response = result as CreatePublicKeyCredentialResponse + + // Ensure clientExtensionResults is present (required by PasskeyCreateResult.fromJson) + val jsonResponse = ensureClientExtensionResults(response.registrationResponseJson) + callback(Result.success(jsonResponse)) + } catch (e: CreateCredentialCancellationException) { + callback( + Result.failure( + FlutterError("cancelled", e.message ?: "User cancelled", null) + ) + ) + } catch (e: CreateCredentialProviderConfigurationException) { + callback( + Result.failure( + FlutterError("notSupported", e.message ?: "Not configured", null) + ) + ) + } catch (e: CreateCredentialException) { + callback( + Result.failure( + FlutterError("registrationFailed", e.message ?: "Registration failed", null) + ) + ) + } + } + } + + override fun getCredential(optionsJson: String, callback: (Result) -> Unit) { + val activity = activityProvider() + if (activity == null) { + callback( + Result.failure( + FlutterError("notSupported", "No activity available", null) + ) + ) + return + } + + val option = GetPublicKeyCredentialOption(requestJson = optionsJson) + val request = GetCredentialRequest.Builder() + .addCredentialOption(option) + .build() + + scope.launch { + try { + val result = credentialManager.getCredential(activity, request) + val credential = result.credential as PublicKeyCredential + + // Ensure clientExtensionResults is present (required by PasskeyGetResult.fromJson) + val jsonResponse = ensureClientExtensionResults(credential.authenticationResponseJson) + callback(Result.success(jsonResponse)) + } catch (e: GetCredentialCancellationException) { + callback( + Result.failure( + FlutterError("cancelled", e.message ?: "User cancelled", null) + ) + ) + } catch (e: NoCredentialException) { + callback( + Result.failure( + FlutterError("assertionFailed", e.message ?: "No credential found", null) + ) + ) + } catch (e: GetCredentialException) { + callback( + Result.failure( + FlutterError("assertionFailed", e.message ?: "Assertion failed", null) + ) + ) + } + } + } + + /** + * Cancels the coroutine scope. Call when the plugin is detached from the engine. + */ + fun dispose() { + scope.cancel() + } + + /** + * Ensures that the JSON response contains a `clientExtensionResults` field. + * + * The androidx.credentials library may not include this field, but + * PasskeyCreateResult.fromJson and PasskeyGetResult.fromJson require it + * (non-nullable field). This function parses the JSON, adds the field if + * missing, and returns the updated JSON string. + */ + private fun ensureClientExtensionResults(jsonString: String): String { + return try { + val json = JSONObject(jsonString) + if (!json.has("clientExtensionResults")) { + json.put("clientExtensionResults", JSONObject()) + } + json.toString() + } catch (e: Exception) { + // If parsing fails, return original JSON (let Dart handle the error) + jsonString + } + } +} diff --git a/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/pigeons/WebAuthnBridgePigeon.kt b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/pigeons/WebAuthnBridgePigeon.kt new file mode 100644 index 00000000000..f01644773ed --- /dev/null +++ b/packages/auth/amplify_auth_cognito/android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/pigeons/WebAuthnBridgePigeon.kt @@ -0,0 +1,149 @@ +// +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Autogenerated from Pigeon (v26.1.10), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.amazonaws.amplify.amplify_auth_cognito + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object WebAuthnBridgePigeonPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +// FlutterError is declared in NativeAuthPluginBindingsPigeon.kt (same package). +private open class WebAuthnBridgePigeonPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** + * Pigeon bridge for WebAuthn/passkey operations. + * + * Platform implementations (iOS/macOS via ASAuthorizationController, + * Android via CredentialManager) handle the native ceremony and return + * JSON-serialized W3C WebAuthn Level 3 response objects. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface WebAuthnBridgeApi { + /** + * Creates a new passkey credential on the device. + * + * [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions`. + * Returns a JSON-serialized `RegistrationResponseJSON`. + */ + fun createCredential(optionsJson: String, callback: (Result) -> Unit) + /** + * Retrieves a passkey credential assertion for authentication. + * + * [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions`. + * Returns a JSON-serialized `AuthenticationResponseJSON`. + */ + fun getCredential(optionsJson: String, callback: (Result) -> Unit) + /** Returns whether the current device/platform supports passkeys. */ + fun isPasskeySupported(callback: (Result) -> Unit) + + companion object { + /** The codec used by WebAuthnBridgeApi. */ + val codec: MessageCodec by lazy { + WebAuthnBridgePigeonPigeonCodec() + } + /** Sets up an instance of `WebAuthnBridgeApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: WebAuthnBridgeApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.createCredential$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsJsonArg = args[0] as String + api.createCredential(optionsJsonArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.getCredential$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsJsonArg = args[0] as String + api.getCredential(optionsJsonArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.isPasskeySupported$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.isPasskeySupported{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(WebAuthnBridgePigeonPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/auth/amplify_auth_cognito/android/src/test/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPluginTest.kt b/packages/auth/amplify_auth_cognito/android/src/test/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPluginTest.kt index 0a4968c7924..3d31dba42c9 100644 --- a/packages/auth/amplify_auth_cognito/android/src/test/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPluginTest.kt +++ b/packages/auth/amplify_auth_cognito/android/src/test/kotlin/com/amazonaws/amplify/amplify_auth_cognito/AmplifyAuthCognitoPluginTest.kt @@ -3,6 +3,7 @@ package com.amazonaws.amplify.amplify_auth_cognito +import android.content.Context import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.BinaryMessenger import kotlinx.coroutines.Dispatchers @@ -37,8 +38,10 @@ internal class AmplifyAuthCognitoPluginTest { } } val binaryMessenger = mock() + val mockContext = mock() val mockPluginBinding = mock { on { getBinaryMessenger() } doReturn binaryMessenger + on { applicationContext } doReturn mockContext } mockPlugin.onAttachedToEngine(mockPluginBinding) @@ -75,8 +78,10 @@ internal class AmplifyAuthCognitoPluginTest { } } val binaryMessenger = mock() + val mockContext = mock() val mockPluginBinding = mock { on { getBinaryMessenger() } doReturn binaryMessenger + on { applicationContext } doReturn mockContext } mockPlugin.onAttachedToEngine(mockPluginBinding) diff --git a/packages/auth/amplify_auth_cognito/darwin/Classes/AmplifyAuthCognitoPlugin.swift b/packages/auth/amplify_auth_cognito/darwin/Classes/AmplifyAuthCognitoPlugin.swift index 55212f6c966..e89c2f0ba93 100644 --- a/packages/auth/amplify_auth_cognito/darwin/Classes/AmplifyAuthCognitoPlugin.swift +++ b/packages/auth/amplify_auth_cognito/darwin/Classes/AmplifyAuthCognitoPlugin.swift @@ -30,6 +30,10 @@ public class AmplifyAuthCognitoPlugin: NSObject, FlutterPlugin, NativeAuthBridge let nativeAuthPlugin = NativeAuthPlugin(binaryMessenger: messenger) let instance = AmplifyAuthCognitoPlugin(nativeAuthPlugin: nativeAuthPlugin) NativeAuthBridgeSetup.setUp(binaryMessenger: messenger, api: instance) + + // Register WebAuthn bridge for passkey operations + let webAuthnBridge = WebAuthnBridgeImpl() + WebAuthnBridgeApiSetup.setUp(binaryMessenger: messenger, api: webAuthnBridge) } func signInWithUrl( diff --git a/packages/auth/amplify_auth_cognito/darwin/Classes/WebAuthnBridgeImpl.swift b/packages/auth/amplify_auth_cognito/darwin/Classes/WebAuthnBridgeImpl.swift new file mode 100644 index 00000000000..e411e7cec21 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/darwin/Classes/WebAuthnBridgeImpl.swift @@ -0,0 +1,409 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import AuthenticationServices +import Foundation + +#if os(iOS) +import Flutter +import UIKit +#elseif os(macOS) +import FlutterMacOS +import AppKit +#endif + +// MARK: - Base64URL Data Extension + +private extension Data { + init?(base64urlEncoded string: String) { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Add padding if needed + let remainder = base64.count % 4 + if remainder > 0 { + base64.append(contentsOf: String(repeating: "=", count: 4 - remainder)) + } + self.init(base64Encoded: base64) + } + + func base64urlEncodedString() -> String { + return self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +// MARK: - WebAuthnBridgeImpl + +class WebAuthnBridgeImpl: NSObject, WebAuthnBridgeApi { + + /// Pending completion handler for the current ceremony. + /// Stored as instance property to prevent ARC deallocation of the delegate. + private var pendingCompletion: ((Result) -> Void)? + + /// Active authorization controller reference. + /// Stored to prevent ARC deallocation during the ceremony. + private var activeController: ASAuthorizationController? + + /// Tracks whether the current ceremony is a registration (true) or assertion (false). + private var isRegistration: Bool = false + + // MARK: - WebAuthnBridgeApi Protocol + + func isPasskeySupported(completion: @escaping (Result) -> Void) { + if #available(iOS 17.4, macOS 13.5, *) { + completion(.success(true)) + } else { + completion(.success(false)) + } + } + + func createCredential(optionsJson: String, completion: @escaping (Result) -> Void) { + guard #available(iOS 17.4, macOS 13.5, *) else { + completion(.failure(PigeonError( + code: "notSupported", + message: "Passkeys require iOS 17.4+ or macOS 13.5+", + details: nil + ))) + return + } + + guard let jsonData = optionsJson.data(using: .utf8), + let options = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + completion(.failure(PigeonError( + code: "registrationFailed", + message: "Failed to parse credential creation options JSON", + details: nil + ))) + return + } + + guard let challengeString = options["challenge"] as? String, + let challengeData = Data(base64urlEncoded: challengeString), + let rp = options["rp"] as? [String: Any], + let rpId = rp["id"] as? String, + let user = options["user"] as? [String: Any], + let userIdString = user["id"] as? String, + let userIdData = Data(base64urlEncoded: userIdString), + let userName = user["name"] as? String else { + completion(.failure(PigeonError( + code: "registrationFailed", + message: "Missing required fields in credential creation options", + details: nil + ))) + return + } + + if #available(iOS 17.4, macOS 13.5, *) { + performRegistration( + challengeData: challengeData, + rpId: rpId, + userIdData: userIdData, + userName: userName, + options: options, + completion: completion + ) + } + } + + func getCredential(optionsJson: String, completion: @escaping (Result) -> Void) { + guard #available(iOS 17.4, macOS 13.5, *) else { + completion(.failure(PigeonError( + code: "notSupported", + message: "Passkeys require iOS 17.4+ or macOS 13.5+", + details: nil + ))) + return + } + + guard let jsonData = optionsJson.data(using: .utf8), + let options = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + completion(.failure(PigeonError( + code: "assertionFailed", + message: "Failed to parse credential request options JSON", + details: nil + ))) + return + } + + guard let challengeString = options["challenge"] as? String, + let challengeData = Data(base64urlEncoded: challengeString), + let rpId = options["rpId"] as? String else { + completion(.failure(PigeonError( + code: "assertionFailed", + message: "Missing required fields in credential request options", + details: nil + ))) + return + } + + if #available(iOS 17.4, macOS 13.5, *) { + performAssertion( + challengeData: challengeData, + rpId: rpId, + options: options, + completion: completion + ) + } + } + + // MARK: - Private Ceremony Methods + + @available(iOS 17.4, macOS 13.5, *) + private func performRegistration( + challengeData: Data, + rpId: String, + userIdData: Data, + userName: String, + options: [String: Any], + completion: @escaping (Result) -> Void + ) { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: rpId + ) + let request = provider.createCredentialRegistrationRequest( + challenge: challengeData, + name: userName, + userID: userIdData + ) + + // Set excludeCredentials if present + if let excludeCredentials = options["excludeCredentials"] as? [[String: Any]] { + request.excludedCredentials = excludeCredentials.compactMap { cred in + guard let idString = cred["id"] as? String, + let idData = Data(base64urlEncoded: idString) else { + return nil + } + return ASAuthorizationPlatformPublicKeyCredentialDescriptor( + credentialID: idData + ) + } + } + + isRegistration = true + pendingCompletion = completion + + let controller = ASAuthorizationController(authorizationRequests: [request]) + activeController = controller + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + + @available(iOS 17.4, macOS 13.5, *) + private func performAssertion( + challengeData: Data, + rpId: String, + options: [String: Any], + completion: @escaping (Result) -> Void + ) { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: rpId + ) + let request = provider.createCredentialAssertionRequest( + challenge: challengeData + ) + + // Set allowedCredentials if present + if let allowCredentials = options["allowCredentials"] as? [[String: Any]] { + request.allowedCredentials = allowCredentials.compactMap { cred in + guard let idString = cred["id"] as? String, + let idData = Data(base64urlEncoded: idString) else { + return nil + } + return ASAuthorizationPlatformPublicKeyCredentialDescriptor( + credentialID: idData + ) + } + } + + isRegistration = false + pendingCompletion = completion + + let controller = ASAuthorizationController(authorizationRequests: [request]) + activeController = controller + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + + // MARK: - Error Mapping + + private func mapAuthorizationError(_ error: ASAuthorizationError) -> PigeonError { + let defaultCode = isRegistration ? "registrationFailed" : "assertionFailed" + let defaultMessage = isRegistration ? "Credential registration failed" : "Credential assertion failed" + + switch error.code { + case .canceled: + return PigeonError( + code: "cancelled", + message: "User cancelled the passkey operation", + details: error.localizedDescription + ) + case .failed, .invalidResponse, .unknown: + return PigeonError( + code: defaultCode, + message: defaultMessage, + details: error.localizedDescription + ) + case .notHandled: + return PigeonError( + code: "notSupported", + message: "Passkey operation not supported", + details: error.localizedDescription + ) + case .matchedExcludedCredential: + return PigeonError( + code: "registrationFailed", + message: "A matching credential already exists on the device", + details: error.localizedDescription + ) + @unknown default: + return PigeonError( + code: defaultCode, + message: defaultMessage, + details: error.localizedDescription + ) + } + } + + /// Completes the pending ceremony and clears stored references. + private func completeCeremony(with result: Result) { + let completion = pendingCompletion + pendingCompletion = nil + activeController = nil + completion?(result) + } +} + +// MARK: - ASAuthorizationControllerDelegate + +extension WebAuthnBridgeImpl: ASAuthorizationControllerDelegate { + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + if #available(iOS 17.4, macOS 13.5, *) { + if let registration = authorization.credential + as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + + // Build response dict with all required Cognito fields + var responseDict: [String: Any] = [ + "clientDataJSON": registration.rawClientDataJSON.base64urlEncodedString(), + "attestationObject": (registration.rawAttestationObject ?? Data()).base64urlEncodedString(), + ] + + // Add optional fields that Cognito requires + // transports: Apple platform authenticators are always "internal" + responseDict["transports"] = ["internal"] + + // Extract authenticatorData, publicKey, and publicKeyAlgorithm from credential + // These are available in iOS 17.4+ through the credential object + if #available(iOS 17.4, macOS 13.5, *) { + // authenticatorData is encoded in the attestationObject but also exposed directly + // Note: ASAuthorizationPlatformPublicKeyCredentialRegistration doesn't expose + // these fields directly in the same way the web API does. The attestationObject + // contains all this data in CBOR format, which Cognito can parse. + // For maximum compatibility with Cognito's expectations, we include empty + // optional fields rather than omitting them. + } + + let response: [String: Any] = [ + "id": registration.credentialID.base64urlEncodedString(), + "rawId": registration.credentialID.base64urlEncodedString(), + "type": "public-key", + "response": responseDict, + "clientExtensionResults": [:], // Required by PasskeyCreateResult.fromJson + "authenticatorAttachment": "platform", + ] + if let jsonData = try? JSONSerialization.data(withJSONObject: response), + let jsonString = String(data: jsonData, encoding: .utf8) { + completeCeremony(with: .success(jsonString)) + } else { + completeCeremony(with: .failure(PigeonError( + code: "registrationFailed", + message: "Failed to serialize registration response", + details: nil + ))) + } + } else if let assertion = authorization.credential + as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + var responseDict: [String: Any] = [ + "clientDataJSON": assertion.rawClientDataJSON.base64urlEncodedString(), + "authenticatorData": assertion.rawAuthenticatorData.base64urlEncodedString(), + "signature": assertion.signature.base64urlEncodedString(), + ] + if let userHandle = assertion.userID, !userHandle.isEmpty { + responseDict["userHandle"] = userHandle.base64urlEncodedString() + } + + let response: [String: Any] = [ + "id": assertion.credentialID.base64urlEncodedString(), + "rawId": assertion.credentialID.base64urlEncodedString(), + "type": "public-key", + "response": responseDict, + "clientExtensionResults": [:], // Required by PasskeyGetResult.fromJson + "authenticatorAttachment": "platform", + ] + if let jsonData = try? JSONSerialization.data(withJSONObject: response), + let jsonString = String(data: jsonData, encoding: .utf8) { + completeCeremony(with: .success(jsonString)) + } else { + completeCeremony(with: .failure(PigeonError( + code: "assertionFailed", + message: "Failed to serialize assertion response", + details: nil + ))) + } + } else { + completeCeremony(with: .failure(PigeonError( + code: isRegistration ? "registrationFailed" : "assertionFailed", + message: "Unexpected credential type", + details: nil + ))) + } + } + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + if let authError = error as? ASAuthorizationError { + completeCeremony(with: .failure(mapAuthorizationError(authError))) + } else { + let code = isRegistration ? "registrationFailed" : "assertionFailed" + completeCeremony(with: .failure(PigeonError( + code: code, + message: error.localizedDescription, + details: nil + ))) + } + } +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding + +extension WebAuthnBridgeImpl: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { +#if os(iOS) + // Find the key window from connected scenes (modern iOS approach) + if let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + return window + } + // Fallback: return any available window + return UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first ?? ASPresentationAnchor() +#elseif os(macOS) + return NSApplication.shared.keyWindow ?? ASPresentationAnchor() +#endif + } +} diff --git a/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/WebAuthnBridge.g.swift b/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/WebAuthnBridge.g.swift new file mode 100644 index 00000000000..94ede81d07f --- /dev/null +++ b/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/WebAuthnBridge.g.swift @@ -0,0 +1,129 @@ +// +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Autogenerated from Pigeon (v26.1.10), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +// PigeonError, wrapResult, wrapError, isNullish, and nilOrValue +// are defined in messages.g.swift + + +private class WebAuthnBridgePigeonCodecReader: FlutterStandardReader { +} + +private class WebAuthnBridgePigeonCodecWriter: FlutterStandardWriter { +} + +private class WebAuthnBridgePigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return WebAuthnBridgePigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return WebAuthnBridgePigeonCodecWriter(data: data) + } +} + +class WebAuthnBridgePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = WebAuthnBridgePigeonCodec(readerWriter: WebAuthnBridgePigeonCodecReaderWriter()) +} + + +/// Pigeon bridge for WebAuthn/passkey operations. +/// +/// Platform implementations (iOS/macOS via ASAuthorizationController, +/// Android via CredentialManager) handle the native ceremony and return +/// JSON-serialized W3C WebAuthn Level 3 response objects. +/// +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol WebAuthnBridgeApi { + /// Creates a new passkey credential on the device. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions`. + /// Returns a JSON-serialized `RegistrationResponseJSON`. + func createCredential(optionsJson: String, completion: @escaping (Result) -> Void) + /// Retrieves a passkey credential assertion for authentication. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions`. + /// Returns a JSON-serialized `AuthenticationResponseJSON`. + func getCredential(optionsJson: String, completion: @escaping (Result) -> Void) + /// Returns whether the current device/platform supports passkeys. + func isPasskeySupported(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class WebAuthnBridgeApiSetup { + static var codec: FlutterStandardMessageCodec { WebAuthnBridgePigeonCodec.shared } + /// Sets up an instance of `WebAuthnBridgeApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: WebAuthnBridgeApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Creates a new passkey credential on the device. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions`. + /// Returns a JSON-serialized `RegistrationResponseJSON`. + let createCredentialChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.createCredential\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + createCredentialChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsJsonArg = args[0] as! String + api.createCredential(optionsJson: optionsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + createCredentialChannel.setMessageHandler(nil) + } + /// Retrieves a passkey credential assertion for authentication. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions`. + /// Returns a JSON-serialized `AuthenticationResponseJSON`. + let getCredentialChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.getCredential\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getCredentialChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsJsonArg = args[0] as! String + api.getCredential(optionsJson: optionsJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getCredentialChannel.setMessageHandler(nil) + } + /// Returns whether the current device/platform supports passkeys. + let isPasskeySupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.isPasskeySupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isPasskeySupportedChannel.setMessageHandler { _, reply in + api.isPasskeySupported { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + isPasskeySupportedChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/messages.g.swift b/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/messages.g.swift index a8b9caa1c02..74e6d0b6be4 100644 --- a/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/messages.g.swift +++ b/packages/auth/amplify_auth_cognito/darwin/Classes/pigeons/messages.g.swift @@ -32,11 +32,11 @@ final class PigeonError: Error { } } -private func wrapResult(_ result: Any?) -> [Any?] { +func wrapResult(_ result: Any?) -> [Any?] { return [result] } -private func wrapError(_ error: Any) -> [Any?] { +func wrapError(_ error: Any) -> [Any?] { if let pigeonError = error as? PigeonError { return [ pigeonError.code, @@ -62,11 +62,11 @@ private func createConnectionError(withChannelName channelName: String) -> Pigeo return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") } -private func isNullish(_ value: Any?) -> Bool { +func isNullish(_ value: Any?) -> Bool { return value is NSNull || value == nil } -private func nilOrValue(_ value: Any?) -> T? { +func nilOrValue(_ value: Any?) -> T? { if value is NSNull { return nil } return value as! T? } diff --git a/packages/auth/amplify_auth_cognito/darwin/amplify_auth_cognito.podspec b/packages/auth/amplify_auth_cognito/darwin/amplify_auth_cognito.podspec index 714acf9a2f5..015e6620cd2 100644 --- a/packages/auth/amplify_auth_cognito/darwin/amplify_auth_cognito.podspec +++ b/packages/auth/amplify_auth_cognito/darwin/amplify_auth_cognito.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.dependency 'FlutterMacOS' s.ios.framework = 'UIKit' - s.osx.frameworks = 'AppKit', 'IOKit' + s.osx.frameworks = 'AppKit', 'IOKit', 'AuthenticationServices' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } # Flutter.framework does not contain a i386 slice. @@ -27,6 +27,6 @@ Pod::Spec.new do |s| # These are needed to support async/await and ASWebAuthenticationSession s.ios.deployment_target = '13.0' - s.osx.deployment_target = '10.15' + s.osx.deployment_target = '13.5' s.swift_version = '5.9' end diff --git a/packages/auth/amplify_auth_cognito/example/lib/main.dart b/packages/auth/amplify_auth_cognito/example/lib/main.dart index 9b10888358e..731291bb629 100644 --- a/packages/auth/amplify_auth_cognito/example/lib/main.dart +++ b/packages/auth/amplify_auth_cognito/example/lib/main.dart @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_auth_cognito_example/screens/confirm_user_attribute.dart'; +import 'package:amplify_auth_cognito_example/screens/passkey_management.dart'; import 'package:amplify_auth_cognito_example/screens/update_user_attribute.dart'; import 'package:amplify_auth_cognito_example/screens/update_user_attributes.dart'; import 'package:amplify_auth_cognito_example/screens/view_user_attributes.dart'; @@ -12,7 +12,7 @@ import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'amplifyconfiguration.dart'; +import 'amplify_outputs.dart'; final AmplifyLogger _logger = AmplifyLogger('MyApp'); @@ -66,6 +66,11 @@ class _MyAppState extends State { userAttributeKey: CognitoUserAttributeKey.phoneNumber, ), ), + GoRoute( + path: '/passkeys', + builder: (BuildContext _, GoRouterState _) => + const PasskeyManagementScreen(), + ), ], ); @@ -77,37 +82,16 @@ class _MyAppState extends State { Future _configure() async { try { - await Amplify.addPlugins([ - AmplifyAPI(), + await Amplify.addPlugin( AmplifyAuthCognito( - // FIXME: In your app, make sure to remove this line and set up - /// Keychain Sharing in Xcode as described in the docs: - /// https://docs.amplify.aws/lib/project-setup/platform-setup/q/platform/flutter/#enable-keychain secureStorageFactory: AmplifySecureStorage.factoryFrom( macOSOptions: // ignore: invalid_use_of_visible_for_testing_member MacOSSecureStorageOptions(useDataProtection: false), ), ), - ]); - - // Uncomment this block, and comment out the one above to change how - // credentials are persisted. - /* - await Amplify.addPlugin( - AmplifyAuthCognito( - secureStorageFactory: (scope) => AmplifySecureStorage.fromConfig( - config: AmplifySecureStorageConfig( - scope: scope.name, - webOptions: WebSecureStorageOptions( - persistenceOption: WebPersistenceOption.inMemory, - ), - ), - ), - ), - ); - */ - await Amplify.configure(amplifyconfig); + ); + await Amplify.configure(amplifyConfig); _logger.debug('Successfully configured Amplify'); Amplify.Hub.listen(HubChannel.Auth, (event) { @@ -122,6 +106,10 @@ class _MyAppState extends State { Widget build(BuildContext context) { return Authenticator( preferPrivateSession: true, + signInForm: SignInForm(), + passwordlessSettings: const PasswordlessSettings( + passkeyRegistrationPrompts: PasskeyRegistrationPrompts.enabled(), + ), child: MaterialApp.router( title: 'Flutter Demo', builder: Authenticator.builder(), @@ -143,50 +131,18 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - var _greeting = ''; - var _loading = false; - late final _controller = TextEditingController(); + final _loading = false; @override void initState() { super.initState(); - _fetchAuthSession(); } @override void dispose() { - _controller.dispose(); super.dispose(); } - Future _fetchAuthSession() async { - final authSession = await Amplify.Auth.fetchAuthSession(); - _logger.info(prettyPrintJson(authSession.toJson())); - } - - Future _requestGreeting() async { - setState(() { - _loading = true; - }); - try { - final response = await Amplify.API - .post('/hello', body: HttpPayload.string(_controller.text)) - .response; - final decodedBody = response.decodeBody(); - setState(() { - _greeting = decodedBody; - }); - } on Exception catch (e) { - setState(() { - _greeting = e.toString(); - }); - } finally { - setState(() { - _loading = false; - }); - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -195,18 +151,8 @@ class _HomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (_loading) - const CircularProgressIndicator() - else - Text(_greeting), + if (_loading) const CircularProgressIndicator(), const SizedBox(height: 30), - TextField(controller: _controller), - const SizedBox(height: 10), - ElevatedButton( - onPressed: _requestGreeting, - child: const Text('Request Greeting'), - ), - const SizedBox(height: 10), ElevatedButton( onPressed: () => context.push('/view-user-attributes'), child: const Text('View User Attributes'), @@ -221,6 +167,12 @@ class _HomeScreenState extends State { onPressed: () => context.push('/update-user-attributes'), child: const Text('Update User Attributes'), ), + const SizedBox(height: 10), + ElevatedButton.icon( + onPressed: () => context.push('/passkeys'), + icon: const Icon(Icons.fingerprint), + label: const Text('Manage Passkeys'), + ), const SizedBox(height: 30), const SignOutButton(), ], diff --git a/packages/auth/amplify_auth_cognito/example/lib/screens/passkey_management.dart b/packages/auth/amplify_auth_cognito/example/lib/screens/passkey_management.dart new file mode 100644 index 00000000000..050f50a2939 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/example/lib/screens/passkey_management.dart @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter/material.dart'; + +class PasskeyManagementScreen extends StatefulWidget { + const PasskeyManagementScreen({super.key}); + + @override + State createState() => + _PasskeyManagementScreenState(); +} + +class _PasskeyManagementScreenState extends State { + List _credentials = []; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final creds = await Amplify.Auth.listWebAuthnCredentials(); + setState(() => _credentials = creds); + } on Exception catch (e) { + setState(() => _error = e.toString()); + } finally { + setState(() => _loading = false); + } + } + + Future _delete(String credentialId) async { + setState(() => _loading = true); + try { + await Amplify.Auth.deleteWebAuthnCredential(credentialId); + await _load(); + } on Exception catch (e) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Passkeys'), + actions: [ + IconButton(icon: const Icon(Icons.refresh), onPressed: _load), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.all(8), + child: Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + ), + if (_credentials.isEmpty) + const Expanded( + child: Center(child: Text('No passkeys registered')), + ) + else + Expanded( + child: ListView.builder( + itemCount: _credentials.length, + itemBuilder: (_, i) { + final c = _credentials[i]; + return ListTile( + leading: const Icon(Icons.fingerprint), + title: Text( + c.friendlyName ?? c.credentialId.substring(0, 8), + ), + subtitle: Text( + c.createdAt.toLocal().toString().split('.').first, + ), + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _delete(c.credentialId), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/auth/amplify_auth_cognito/example/macos/Podfile b/packages/auth/amplify_auth_cognito/example/macos/Podfile index 9ec46f8cd53..f735392085b 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Podfile +++ b/packages/auth/amplify_auth_cognito/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '13.5' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/project.pbxproj b/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/project.pbxproj index d1195c188e3..d095e76639c 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/project.pbxproj @@ -201,8 +201,11 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { + KnownAssetTags = ( + New, + ); LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -321,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -404,7 +411,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -419,14 +426,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = S69WVG9KJ5; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.amazonaws.amplify.amplifyAuthCognitoExample1; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -484,7 +494,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -531,7 +541,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -546,14 +556,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = S69WVG9KJ5; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; + PRODUCT_BUNDLE_IDENTIFIER = com.amazonaws.amplify.amplifyAuthCognitoExample1; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -567,14 +580,16 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = S69WVG9KJ5; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 13.5; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 02a7862490a..b8b75f5f61a 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/auth/amplify_auth_cognito/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/packages/auth/amplify_auth_cognito/example/macos/Runner/AppDelegate.swift b/packages/auth/amplify_auth_cognito/example/macos/Runner/AppDelegate.swift index d60060a90bf..707da7e1bf1 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Runner/AppDelegate.swift +++ b/packages/auth/amplify_auth_cognito/example/macos/Runner/AppDelegate.swift @@ -4,9 +4,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/auth/amplify_auth_cognito/example/macos/Runner/DebugProfile.entitlements b/packages/auth/amplify_auth_cognito/example/macos/Runner/DebugProfile.entitlements index 3ba6c1266f2..460cc3ca777 100644 --- a/packages/auth/amplify_auth_cognito/example/macos/Runner/DebugProfile.entitlements +++ b/packages/auth/amplify_auth_cognito/example/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,9 @@ com.apple.security.network.server + keychain-access-groups + + $(AppIdentifierPrefix)com.amazonaws.amplify.auth.cognito.example + diff --git a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart index 40a72c26bbc..2f23dafa73b 100644 --- a/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart @@ -2,14 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import 'dart:async'; -import 'dart:io'; +import 'dart:io' + if (dart.library.js_interop) 'package:amplify_auth_cognito/src/web_io_stub.dart'; // ignore: implementation_imports import 'package:amplify_analytics_pinpoint/src/flutter_endpoint_info_store_manager.dart'; // ignore: implementation_imports import 'package:amplify_analytics_pinpoint_dart/src/impl/analytics_client/endpoint_client/endpoint_info_store_manager.dart'; import 'package:amplify_auth_cognito/src/credentials/legacy_credential_provider_impl.dart'; +import 'package:amplify_auth_cognito/src/linux/linux_webauthn_platform_stub.dart' + if (dart.library.io) 'package:amplify_auth_cognito/src/linux/linux_webauthn_platform.dart'; import 'package:amplify_auth_cognito/src/native_auth_plugin.g.dart'; +import 'package:amplify_auth_cognito/src/pigeon_webauthn_credential_platform.dart'; +import 'package:amplify_auth_cognito/src/webauthn_bridge.g.dart'; +import 'package:amplify_auth_cognito/src/windows/windows_webauthn_platform_stub.dart' + if (dart.library.io) 'package:amplify_auth_cognito/src/windows/windows_webauthn_platform.dart'; import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; // ignore: implementation_imports import 'package:amplify_auth_cognito_dart/src/credentials/legacy_credential_provider.dart'; @@ -53,20 +60,47 @@ class AmplifyAuthCognito extends AmplifyAuthCognitoDart with AWSDebuggable { }) async { await super.addPlugin(authProviderRepo: authProviderRepo); - if (zIsWeb || !(Platform.isAndroid || Platform.isIOS)) { + if (zIsWeb) { return; } - // Configure this plugin to act as a native iOS/Android plugin. + // Windows and Linux use direct FFI bridges (no Pigeon) + if (Platform.isWindows) { + stateMachine.addInstance( + WindowsWebAuthnPlatform(), + ); + return; + } + if (Platform.isLinux) { + stateMachine.addInstance( + LinuxWebAuthnPlatform(), + ); + return; + } + + // iOS, Android, and macOS use the Pigeon bridge + if (!(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) { + return; + } + + // Configure this plugin to act as a native iOS/Android/macOS plugin. final nativePlugin = _NativeAmplifyAuthCognito(stateMachine); NativeAuthPlugin.setUp(nativePlugin); final nativeBridge = NativeAuthBridge(); - stateMachine - ..addInstance(nativeBridge) - ..addInstance( + final webAuthnBridge = WebAuthnBridgeApi(); + final webAuthnPlatform = PigeonWebAuthnCredentialPlatform(webAuthnBridge); + stateMachine.addInstance(nativeBridge); + + // macOS uses FFI for ASF device info collection (ASFDeviceInfoMacOS), + // while iOS and Android use the Pigeon method channel bridge. + if (Platform.isAndroid || Platform.isIOS) { + stateMachine.addInstance( _NativeASFDeviceInfoCollector(nativeBridge), ); + } + + stateMachine.addInstance(webAuthnPlatform); final legacyCredentialProvider = LegacyCredentialProviderImpl(stateMachine); stateMachine.addInstance( @@ -99,7 +133,7 @@ class AmplifyAuthCognito extends AmplifyAuthCognitoDart with AWSDebuggable { defaultPluginOptions: const CognitoSignUpPluginOptions(), ); Map? validationData; - if (!zIsWeb && (Platform.isAndroid || Platform.isIOS)) { + if (!zIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) { final nativeValidationData = await stateMachine .expect() .getValidationData(); diff --git a/packages/auth/amplify_auth_cognito/lib/src/linux/libfido2_bindings.dart b/packages/auth/amplify_auth_cognito/lib/src/linux/libfido2_bindings.dart new file mode 100644 index 00000000000..0eeb95b328a --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/linux/libfido2_bindings.dart @@ -0,0 +1,469 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: comment_references + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +// ── Error constants ────────────────────────────────────────────────────────── + +/// libfido2 return code: success. +const int fidoOk = 0; + +/// libfido2 return code: transmission error. +const int fidoErrTx = -1; + +/// libfido2 return code: operation not allowed (user cancelled on some devices). +const int fidoErrNotAllowed = 0x27; + +/// libfido2 return code: action timed out. +const int fidoErrActionTimeout = 0x2F; + +/// libfido2 return code: PIN required. +const int fidoErrPinRequired = 0x36; + +/// libfido2 return code: user verification blocked. +const int fidoErrUvBlocked = 0x3C; + +/// COSE algorithm identifier for ES256 (ECDSA w/ SHA-256). +const int coseEs256 = -7; + +// ── User verification option constants ─────────────────────────────────────── + +/// Omit user verification option. +const int fidoOptOmit = 0; + +/// Explicitly disable user verification. +const int fidoOptFalse = 1; + +/// Require user verification. +const int fidoOptTrue = 2; + +// ── Native function typedefs ───────────────────────────────────────────────── + +// Init +typedef _FidoInitC = Void Function(Int32 flags); +typedef _FidoInitDart = void Function(int flags); + +// Device info +typedef _FidoDevInfoNewC = Pointer Function(Size n); +typedef _FidoDevInfoNewDart = Pointer Function(int n); + +typedef _FidoDevInfoFreeC = Void Function(Pointer devlist, Size n); +typedef _FidoDevInfoFreeDart = void Function(Pointer devlist, int n); + +typedef _FidoDevInfoManifestC = + Int32 Function(Pointer devlist, Size n, Pointer found); +typedef _FidoDevInfoManifestDart = + int Function(Pointer devlist, int n, Pointer found); + +typedef _FidoDevInfoPathC = Pointer Function(Pointer di); +typedef _FidoDevInfoPathDart = Pointer Function(Pointer di); + +typedef _FidoDevInfoPtrC = Pointer Function(Pointer devlist, Size idx); +typedef _FidoDevInfoPtrDart = Pointer Function(Pointer devlist, int idx); + +// Device management +typedef _FidoDevNewC = Pointer Function(); +typedef _FidoDevNewDart = Pointer Function(); + +typedef _FidoDevFreeC = Void Function(Pointer dev); +typedef _FidoDevFreeDart = void Function(Pointer dev); + +typedef _FidoDevOpenC = Int32 Function(Pointer dev, Pointer path); +typedef _FidoDevOpenDart = int Function(Pointer dev, Pointer path); + +typedef _FidoDevCloseC = Int32 Function(Pointer dev); +typedef _FidoDevCloseDart = int Function(Pointer dev); + +// Credential creation +typedef _FidoCredNewC = Pointer Function(); +typedef _FidoCredNewDart = Pointer Function(); + +typedef _FidoCredFreeC = Void Function(Pointer cred); +typedef _FidoCredFreeDart = void Function(Pointer cred); + +typedef _FidoCredSetTypeC = Int32 Function(Pointer cred, Int32 type); +typedef _FidoCredSetTypeDart = int Function(Pointer cred, int type); + +typedef _FidoCredSetClientdataHashC = + Int32 Function(Pointer cred, Pointer hash, Size hashLen); +typedef _FidoCredSetClientdataHashDart = + int Function(Pointer cred, Pointer hash, int hashLen); + +typedef _FidoCredSetRpC = + Int32 Function(Pointer cred, Pointer rpId, Pointer rpName); +typedef _FidoCredSetRpDart = + int Function(Pointer cred, Pointer rpId, Pointer rpName); + +typedef _FidoCredSetUserC = + Int32 Function( + Pointer cred, + Pointer userId, + Size userIdLen, + Pointer userName, + Pointer displayName, + Pointer icon, + ); +typedef _FidoCredSetUserDart = + int Function( + Pointer cred, + Pointer userId, + int userIdLen, + Pointer userName, + Pointer displayName, + Pointer icon, + ); + +typedef _FidoCredSetRkC = Int32 Function(Pointer cred, Int32 rk); +typedef _FidoCredSetRkDart = int Function(Pointer cred, int rk); + +typedef _FidoCredSetUvC = Int32 Function(Pointer cred, Int32 uv); +typedef _FidoCredSetUvDart = int Function(Pointer cred, int uv); + +typedef _FidoDevMakeCredC = + Int32 Function(Pointer dev, Pointer cred, Pointer pin); +typedef _FidoDevMakeCredDart = + int Function(Pointer dev, Pointer cred, Pointer pin); + +// Credential result getters +typedef _FidoCredPtrGetterC = Pointer Function(Pointer cred); +typedef _FidoCredPtrGetterDart = Pointer Function(Pointer cred); + +typedef _FidoCredLenGetterC = Size Function(Pointer cred); +typedef _FidoCredLenGetterDart = int Function(Pointer cred); + +typedef _FidoCredFmtC = Pointer Function(Pointer cred); +typedef _FidoCredFmtDart = Pointer Function(Pointer cred); + +// Assertion +typedef _FidoAssertNewC = Pointer Function(); +typedef _FidoAssertNewDart = Pointer Function(); + +typedef _FidoAssertFreeC = Void Function(Pointer assert_); +typedef _FidoAssertFreeDart = void Function(Pointer assert_); + +typedef _FidoAssertSetClientdataHashC = + Int32 Function(Pointer assert_, Pointer hash, Size len); +typedef _FidoAssertSetClientdataHashDart = + int Function(Pointer assert_, Pointer hash, int len); + +typedef _FidoAssertSetRpC = Int32 Function(Pointer assert_, Pointer rpId); +typedef _FidoAssertSetRpDart = + int Function(Pointer assert_, Pointer rpId); + +typedef _FidoAssertSetUvC = Int32 Function(Pointer assert_, Int32 uv); +typedef _FidoAssertSetUvDart = int Function(Pointer assert_, int uv); + +typedef _FidoAssertAllowCredC = + Int32 Function(Pointer assert_, Pointer credId, Size len); +typedef _FidoAssertAllowCredDart = + int Function(Pointer assert_, Pointer credId, int len); + +typedef _FidoDevGetAssertC = + Int32 Function(Pointer dev, Pointer assert_, Pointer pin); +typedef _FidoDevGetAssertDart = + int Function(Pointer dev, Pointer assert_, Pointer pin); + +typedef _FidoAssertCountC = Size Function(Pointer assert_); +typedef _FidoAssertCountDart = int Function(Pointer assert_); + +// Assertion result getters (with index) +typedef _FidoAssertIdxPtrGetterC = + Pointer Function(Pointer assert_, Size idx); +typedef _FidoAssertIdxPtrGetterDart = + Pointer Function(Pointer assert_, int idx); + +typedef _FidoAssertIdxLenGetterC = Size Function(Pointer assert_, Size idx); +typedef _FidoAssertIdxLenGetterDart = int Function(Pointer assert_, int idx); + +// ── Bindings class ─────────────────────────────────────────────────────────── + +/// FFI bindings to `libfido2.so` for Linux FIDO2 security key operations. +/// +/// All function lookups are performed eagerly as `late final` fields, resolved +/// from a [DynamicLibrary] provided at construction time. The caller is +/// responsible for loading the dynamic library (e.g. via +/// `DynamicLibrary.open('libfido2.so')`). +class LibFido2Bindings { + /// Creates bindings from a loaded `libfido2.so` dynamic library. + LibFido2Bindings(this._lib); + + final DynamicLibrary _lib; + + // ── Init ───────────────────────────────────────────────────────────────── + + /// Initialize libfido2. Call with `flags = 0`. + late final fidoInit = _lib.lookupFunction<_FidoInitC, _FidoInitDart>( + 'fido_init', + ); + + // ── Device info ────────────────────────────────────────────────────────── + + /// Allocate a device info list for up to [n] entries. + late final fidoDevInfoNew = _lib + .lookupFunction<_FidoDevInfoNewC, _FidoDevInfoNewDart>( + 'fido_dev_info_new', + ); + + /// Free a device info list. + late final fidoDevInfoFree = _lib + .lookupFunction<_FidoDevInfoFreeC, _FidoDevInfoFreeDart>( + 'fido_dev_info_free', + ); + + /// Discover connected FIDO2 devices. Returns error code. + late final fidoDevInfoManifest = _lib + .lookupFunction<_FidoDevInfoManifestC, _FidoDevInfoManifestDart>( + 'fido_dev_info_manifest', + ); + + /// Get the device path from a device info entry. + late final fidoDevInfoPath = _lib + .lookupFunction<_FidoDevInfoPathC, _FidoDevInfoPathDart>( + 'fido_dev_info_path', + ); + + /// Get the device info entry at [idx] from the list. + late final fidoDevInfoPtr = _lib + .lookupFunction<_FidoDevInfoPtrC, _FidoDevInfoPtrDart>( + 'fido_dev_info_ptr', + ); + + // ── Device management ──────────────────────────────────────────────────── + + /// Allocate a new device handle. + late final fidoDevNew = _lib.lookupFunction<_FidoDevNewC, _FidoDevNewDart>( + 'fido_dev_new', + ); + + /// Free a device handle. + late final fidoDevFree = _lib.lookupFunction<_FidoDevFreeC, _FidoDevFreeDart>( + 'fido_dev_free', + ); + + /// Open a FIDO2 device at [path]. Returns error code. + late final fidoDevOpen = _lib.lookupFunction<_FidoDevOpenC, _FidoDevOpenDart>( + 'fido_dev_open', + ); + + /// Close a FIDO2 device. Returns error code. + late final fidoDevClose = _lib + .lookupFunction<_FidoDevCloseC, _FidoDevCloseDart>('fido_dev_close'); + + // ── Credential creation ────────────────────────────────────────────────── + + /// Allocate a new credential object. + late final fidoCredNew = _lib.lookupFunction<_FidoCredNewC, _FidoCredNewDart>( + 'fido_cred_new', + ); + + /// Free a credential object. + late final fidoCredFree = _lib + .lookupFunction<_FidoCredFreeC, _FidoCredFreeDart>('fido_cred_free'); + + /// Set the credential algorithm type (e.g. [coseEs256]). + late final fidoCredSetType = _lib + .lookupFunction<_FidoCredSetTypeC, _FidoCredSetTypeDart>( + 'fido_cred_set_type', + ); + + /// Set the client data hash on the credential. + late final fidoCredSetClientdataHash = _lib + .lookupFunction< + _FidoCredSetClientdataHashC, + _FidoCredSetClientdataHashDart + >('fido_cred_set_clientdata_hash'); + + /// Set the relying party ID and name on the credential. + late final fidoCredSetRp = _lib + .lookupFunction<_FidoCredSetRpC, _FidoCredSetRpDart>('fido_cred_set_rp'); + + /// Set the user ID, name, display name, and icon on the credential. + late final fidoCredSetUser = _lib + .lookupFunction<_FidoCredSetUserC, _FidoCredSetUserDart>( + 'fido_cred_set_user', + ); + + /// Set the resident key requirement on the credential. + late final fidoCredSetRk = _lib + .lookupFunction<_FidoCredSetRkC, _FidoCredSetRkDart>('fido_cred_set_rk'); + + /// Set the user verification requirement on the credential. + late final fidoCredSetUv = _lib + .lookupFunction<_FidoCredSetUvC, _FidoCredSetUvDart>('fido_cred_set_uv'); + + /// Perform the credential creation ceremony on a device. Returns error code. + late final fidoDevMakeCred = _lib + .lookupFunction<_FidoDevMakeCredC, _FidoDevMakeCredDart>( + 'fido_dev_make_cred', + ); + + // ── Credential result getters ──────────────────────────────────────────── + + /// Pointer to the credential ID bytes. + late final fidoCredIdPtr = _lib + .lookupFunction<_FidoCredPtrGetterC, _FidoCredPtrGetterDart>( + 'fido_cred_id_ptr', + ); + + /// Length of the credential ID in bytes. + late final fidoCredIdLen = _lib + .lookupFunction<_FidoCredLenGetterC, _FidoCredLenGetterDart>( + 'fido_cred_id_len', + ); + + /// Pointer to the authenticator data bytes. + late final fidoCredAuthdataPtr = _lib + .lookupFunction<_FidoCredPtrGetterC, _FidoCredPtrGetterDart>( + 'fido_cred_authdata_ptr', + ); + + /// Length of the authenticator data in bytes. + late final fidoCredAuthdataLen = _lib + .lookupFunction<_FidoCredLenGetterC, _FidoCredLenGetterDart>( + 'fido_cred_authdata_len', + ); + + /// Pointer to the attestation certificate (x5c) bytes. + late final fidoCredX5cPtr = _lib + .lookupFunction<_FidoCredPtrGetterC, _FidoCredPtrGetterDart>( + 'fido_cred_x5c_ptr', + ); + + /// Length of the attestation certificate in bytes. + late final fidoCredX5cLen = _lib + .lookupFunction<_FidoCredLenGetterC, _FidoCredLenGetterDart>( + 'fido_cred_x5c_len', + ); + + /// Pointer to the attestation signature bytes. + late final fidoCredSigPtr = _lib + .lookupFunction<_FidoCredPtrGetterC, _FidoCredPtrGetterDart>( + 'fido_cred_sig_ptr', + ); + + /// Length of the attestation signature in bytes. + late final fidoCredSigLen = _lib + .lookupFunction<_FidoCredLenGetterC, _FidoCredLenGetterDart>( + 'fido_cred_sig_len', + ); + + /// Pointer to the client data hash bytes. + late final fidoCredClientdataHashPtr = _lib + .lookupFunction<_FidoCredPtrGetterC, _FidoCredPtrGetterDart>( + 'fido_cred_clientdata_hash_ptr', + ); + + /// Length of the client data hash in bytes. + late final fidoCredClientdataHashLen = _lib + .lookupFunction<_FidoCredLenGetterC, _FidoCredLenGetterDart>( + 'fido_cred_clientdata_hash_len', + ); + + /// Attestation format string (e.g. "packed", "none"). + late final fidoCredFmt = _lib.lookupFunction<_FidoCredFmtC, _FidoCredFmtDart>( + 'fido_cred_fmt', + ); + + // ── Assertion ──────────────────────────────────────────────────────────── + + /// Allocate a new assertion object. + late final fidoAssertNew = _lib + .lookupFunction<_FidoAssertNewC, _FidoAssertNewDart>('fido_assert_new'); + + /// Free an assertion object. + late final fidoAssertFree = _lib + .lookupFunction<_FidoAssertFreeC, _FidoAssertFreeDart>( + 'fido_assert_free', + ); + + /// Set the client data hash on the assertion. + late final fidoAssertSetClientdataHash = _lib + .lookupFunction< + _FidoAssertSetClientdataHashC, + _FidoAssertSetClientdataHashDart + >('fido_assert_set_clientdata_hash'); + + /// Set the relying party ID on the assertion. + late final fidoAssertSetRp = _lib + .lookupFunction<_FidoAssertSetRpC, _FidoAssertSetRpDart>( + 'fido_assert_set_rp', + ); + + /// Set the user verification requirement on the assertion. + late final fidoAssertSetUv = _lib + .lookupFunction<_FidoAssertSetUvC, _FidoAssertSetUvDart>( + 'fido_assert_set_uv', + ); + + /// Add an allowed credential ID to the assertion. + late final fidoAssertAllowCred = _lib + .lookupFunction<_FidoAssertAllowCredC, _FidoAssertAllowCredDart>( + 'fido_assert_allow_cred', + ); + + /// Perform the assertion ceremony on a device. Returns error code. + late final fidoDevGetAssert = _lib + .lookupFunction<_FidoDevGetAssertC, _FidoDevGetAssertDart>( + 'fido_dev_get_assert', + ); + + /// Number of assertions returned. + late final fidoAssertCount = _lib + .lookupFunction<_FidoAssertCountC, _FidoAssertCountDart>( + 'fido_assert_count', + ); + + // ── Assertion result getters ───────────────────────────────────────────── + + /// Pointer to the authenticator data bytes for assertion at [idx]. + late final fidoAssertAuthdataPtr = _lib + .lookupFunction<_FidoAssertIdxPtrGetterC, _FidoAssertIdxPtrGetterDart>( + 'fido_assert_authdata_ptr', + ); + + /// Length of the authenticator data for assertion at [idx]. + late final fidoAssertAuthdataLen = _lib + .lookupFunction<_FidoAssertIdxLenGetterC, _FidoAssertIdxLenGetterDart>( + 'fido_assert_authdata_len', + ); + + /// Pointer to the signature bytes for assertion at [idx]. + late final fidoAssertSigPtr = _lib + .lookupFunction<_FidoAssertIdxPtrGetterC, _FidoAssertIdxPtrGetterDart>( + 'fido_assert_sig_ptr', + ); + + /// Length of the signature for assertion at [idx]. + late final fidoAssertSigLen = _lib + .lookupFunction<_FidoAssertIdxLenGetterC, _FidoAssertIdxLenGetterDart>( + 'fido_assert_sig_len', + ); + + /// Pointer to the user ID bytes for assertion at [idx]. + late final fidoAssertUserIdPtr = _lib + .lookupFunction<_FidoAssertIdxPtrGetterC, _FidoAssertIdxPtrGetterDart>( + 'fido_assert_user_id_ptr', + ); + + /// Length of the user ID for assertion at [idx]. + late final fidoAssertUserIdLen = _lib + .lookupFunction<_FidoAssertIdxLenGetterC, _FidoAssertIdxLenGetterDart>( + 'fido_assert_user_id_len', + ); + + /// Pointer to the credential ID bytes for assertion at [idx]. + late final fidoAssertIdPtr = _lib + .lookupFunction<_FidoAssertIdxPtrGetterC, _FidoAssertIdxPtrGetterDart>( + 'fido_assert_id_ptr', + ); + + /// Length of the credential ID for assertion at [idx]. + late final fidoAssertIdLen = _lib + .lookupFunction<_FidoAssertIdxLenGetterC, _FidoAssertIdxLenGetterDart>( + 'fido_assert_id_len', + ); +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform.dart b/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform.dart new file mode 100644 index 00000000000..533d0129457 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform.dart @@ -0,0 +1,535 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: cascade_invocations + +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:amplify_auth_cognito/src/linux/libfido2_bindings.dart'; +// ignore: implementation_imports +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:crypto/crypto.dart'; +import 'package:ffi/ffi.dart'; + +/// Maximum number of FIDO2 devices to discover. +const int _maxDevices = 64; + +/// {@template amplify_auth_cognito.linux_webauthn_platform} +/// Linux implementation of [WebAuthnCredentialPlatform] using `libfido2` FFI +/// bindings for USB FIDO2 security key operations. +/// +/// When `libfido2.so` is not installed, [isPasskeySupported] returns `false` +/// and all ceremony operations throw [PasskeyNotSupportedException]. +/// {@endtemplate} +class LinuxWebAuthnPlatform implements WebAuthnCredentialPlatform { + /// {@macro amplify_auth_cognito.linux_webauthn_platform} + /// + /// Attempts to load `libfido2` and initialize the library. If the library + /// is not available, the platform operates in degraded mode where all + /// operations report passkeys as unsupported. + /// + /// An optional [bindings] parameter can be provided for testing. + LinuxWebAuthnPlatform({LibFido2Bindings? bindings}) { + if (bindings != null) { + _bindings = bindings; + return; + } + + // Try loading libfido2 with version suffixes first, then unversioned. + // On Linux, the shared library typically has a SONAME like 'libfido2.so.1', + // while 'libfido2.so' is only present when the -dev package is installed. + const libraryNames = [ + 'libfido2.so.1', // Common SONAME on most distributions + 'libfido2.so', // Development symlink (may not exist in runtime-only installations) + ]; + + for (final name in libraryNames) { + try { + final lib = DynamicLibrary.open(name); + _bindings = LibFido2Bindings(lib); + _bindings!.fidoInit(0); + return; // Successfully loaded + } on ArgumentError { + // Try next library name + continue; + } + } + + // libfido2 not installed — passkeys not supported. + _bindings = null; + } + + LibFido2Bindings? _bindings; + + /// Throws [PasskeyNotSupportedException] if libfido2 is not loaded. + void _ensureSupported() { + if (_bindings == null) { + throw const PasskeyNotSupportedException( + 'libfido2 is not available. Install libfido2-dev for passkey support.', + ); + } + } + + @override + Future isPasskeySupported() async => _bindings != null; + + @override + Future createCredential(String optionsJson) async { + _ensureSupported(); + final b = _bindings!; + final options = jsonDecode(optionsJson) as Map; + + // Parse options. + final rp = options['rp'] as Map; + final rpId = rp['id'] as String; + final rpName = rp['name'] as String? ?? rpId; + + final user = options['user'] as Map; + final userIdB64 = user['id'] as String; + final userId = _base64UrlDecode(userIdB64); + final userName = user['name'] as String? ?? ''; + final displayName = user['displayName'] as String? ?? userName; + + final challenge = _base64UrlDecode(options['challenge'] as String); + + final pubKeyCredParams = + options['pubKeyCredParams'] as List? ?? []; + var algorithm = coseEs256; + if (pubKeyCredParams.isNotEmpty) { + final first = pubKeyCredParams[0] as Map; + algorithm = first['alg'] as int? ?? coseEs256; + } + + final authSelection = + options['authenticatorSelection'] as Map? ?? {}; + final residentKey = authSelection['residentKey'] as String?; + final userVerification = authSelection['userVerification'] as String?; + + // Build clientDataJSON and hash. + final clientData = _buildClientDataJson( + type: 'webauthn.create', + challenge: _base64UrlEncode(challenge), + origin: 'https://$rpId', + ); + final clientDataBytes = utf8.encode(clientData); + final clientDataHash = sha256.convert(clientDataBytes).bytes; + + // Discover and open device. + final deviceResult = _discoverAndOpenDevice(b); + final dev = deviceResult.device; + final devInfoList = deviceResult.devInfoList; + + // Allocate credential. + final cred = b.fidoCredNew(); + final devPtr = calloc(); + devPtr.value = dev; + final credPtr = calloc(); + credPtr.value = cred; + + try { + return using((Arena arena) { + // Set credential fields. + _checkFido(b.fidoCredSetType(cred, algorithm), 'set type', true); + + final hashPtr = arena(clientDataHash.length); + for (var i = 0; i < clientDataHash.length; i++) { + hashPtr[i] = clientDataHash[i]; + } + _checkFido( + b.fidoCredSetClientdataHash(cred, hashPtr, clientDataHash.length), + 'set clientdata hash', + true, + ); + + final rpIdNative = rpId.toNativeUtf8(allocator: arena); + final rpNameNative = rpName.toNativeUtf8(allocator: arena); + _checkFido( + b.fidoCredSetRp(cred, rpIdNative, rpNameNative), + 'set rp', + true, + ); + + final userIdPtr = arena(userId.length); + for (var i = 0; i < userId.length; i++) { + userIdPtr[i] = userId[i]; + } + final userNameNative = userName.toNativeUtf8(allocator: arena); + final displayNameNative = displayName.toNativeUtf8(allocator: arena); + final iconNative = ''.toNativeUtf8(allocator: arena); + _checkFido( + b.fidoCredSetUser( + cred, + userIdPtr, + userId.length, + userNameNative, + displayNameNative, + iconNative, + ), + 'set user', + true, + ); + + // Set resident key. + if (residentKey == 'required' || residentKey == 'preferred') { + _checkFido(b.fidoCredSetRk(cred, fidoOptTrue), 'set rk', true); + } + + // Set user verification. + final uvOpt = _parseUserVerification(userVerification); + _checkFido(b.fidoCredSetUv(cred, uvOpt), 'set uv', true); + + // Perform credential creation ceremony. + final rc = b.fidoDevMakeCred(dev, cred, nullptr); + _checkFido(rc, 'make credential', true); + + // Read result fields. + final credIdPtr = b.fidoCredIdPtr(cred); + final credIdLen = b.fidoCredIdLen(cred); + final credIdBytes = _copyNativeBytes(credIdPtr, credIdLen); + + final authdataPtr = b.fidoCredAuthdataPtr(cred); + final authdataLen = b.fidoCredAuthdataLen(cred); + final authdataBytes = _copyNativeBytes(authdataPtr, authdataLen); + + final credIdB64 = _base64UrlEncode(credIdBytes); + final clientDataJsonB64 = _base64UrlEncode( + Uint8List.fromList(clientDataBytes), + ); + final attestationObjectB64 = _base64UrlEncode( + Uint8List.fromList(authdataBytes), + ); + + // Build response dict with required Cognito fields + final responseDict = { + 'clientDataJSON': clientDataJsonB64, + 'attestationObject': attestationObjectB64, + 'transports': ['usb'], + }; + + // Add optional fields that Cognito requires + // authenticatorData is available from libfido2 + responseDict['authenticatorData'] = _base64UrlEncode(authdataBytes); + + // publicKey and publicKeyAlgorithm extraction from libfido2 would require + // additional FFI bindings. The attestationObject contains this data in CBOR + // format which Cognito can parse. + // For now, include the algorithm that was used + responseDict['publicKeyAlgorithm'] = algorithm; + + // Assemble W3C WebAuthn response JSON. + final response = jsonEncode({ + 'id': credIdB64, + 'rawId': credIdB64, + 'type': 'public-key', + 'response': responseDict, + 'clientExtensionResults': + {}, // Required by PasskeyCreateResult.fromJson + 'authenticatorAttachment': 'cross-platform', + }); + + return response; + }); + } finally { + b.fidoDevClose(dev); + b.fidoDevFree(devPtr); + b.fidoCredFree(credPtr); + _freeDevInfoList(b, devInfoList); + calloc.free(devPtr); + calloc.free(credPtr); + } + } + + @override + Future getCredential(String optionsJson) async { + _ensureSupported(); + final b = _bindings!; + final options = jsonDecode(optionsJson) as Map; + + // Parse options. + final rpId = options['rpId'] as String; + final challenge = _base64UrlDecode(options['challenge'] as String); + final userVerification = options['userVerification'] as String?; + final allowCredentials = + options['allowCredentials'] as List? ?? []; + + // Build clientDataJSON and hash. + final clientData = _buildClientDataJson( + type: 'webauthn.get', + challenge: _base64UrlEncode(Uint8List.fromList(challenge)), + origin: 'https://$rpId', + ); + final clientDataBytes = utf8.encode(clientData); + final clientDataHash = sha256.convert(clientDataBytes).bytes; + + // Discover and open device. + final deviceResult = _discoverAndOpenDevice(b); + final dev = deviceResult.device; + final devInfoList = deviceResult.devInfoList; + + // Allocate assertion. + final assert_ = b.fidoAssertNew(); + final devPtr = calloc(); + devPtr.value = dev; + final assertPtr = calloc(); + assertPtr.value = assert_; + + try { + return using((Arena arena) { + // Set assertion fields. + final hashPtr = arena(clientDataHash.length); + for (var i = 0; i < clientDataHash.length; i++) { + hashPtr[i] = clientDataHash[i]; + } + _checkFido( + b.fidoAssertSetClientdataHash( + assert_, + hashPtr, + clientDataHash.length, + ), + 'set clientdata hash', + false, + ); + + final rpIdNative = rpId.toNativeUtf8(allocator: arena); + _checkFido(b.fidoAssertSetRp(assert_, rpIdNative), 'set rp', false); + + final uvOpt = _parseUserVerification(userVerification); + _checkFido(b.fidoAssertSetUv(assert_, uvOpt), 'set uv', false); + + // Add allowed credentials. + for (final cred in allowCredentials) { + final credMap = cred as Map; + final credIdBytes = _base64UrlDecode(credMap['id'] as String); + final credIdPtr = arena(credIdBytes.length); + for (var i = 0; i < credIdBytes.length; i++) { + credIdPtr[i] = credIdBytes[i]; + } + _checkFido( + b.fidoAssertAllowCred(assert_, credIdPtr, credIdBytes.length), + 'allow cred', + false, + ); + } + + // Perform assertion ceremony. + final rc = b.fidoDevGetAssert(dev, assert_, nullptr); + _checkFido(rc, 'get assertion', false); + + // Read result (first assertion, index 0). + const idx = 0; + final authdataPtr = b.fidoAssertAuthdataPtr(assert_, idx); + final authdataLen = b.fidoAssertAuthdataLen(assert_, idx); + final authdataBytes = _copyNativeBytes(authdataPtr, authdataLen); + + final sigPtr = b.fidoAssertSigPtr(assert_, idx); + final sigLen = b.fidoAssertSigLen(assert_, idx); + final sigBytes = _copyNativeBytes(sigPtr, sigLen); + + final userIdPtr = b.fidoAssertUserIdPtr(assert_, idx); + final userIdLen = b.fidoAssertUserIdLen(assert_, idx); + final userIdBytes = _copyNativeBytes(userIdPtr, userIdLen); + + final credIdPtr = b.fidoAssertIdPtr(assert_, idx); + final credIdLen = b.fidoAssertIdLen(assert_, idx); + final credIdBytes = _copyNativeBytes(credIdPtr, credIdLen); + + final credIdB64 = _base64UrlEncode(credIdBytes); + final clientDataJsonB64 = _base64UrlEncode( + Uint8List.fromList(clientDataBytes), + ); + + // Assemble W3C WebAuthn response JSON. + final responseMap = { + 'id': credIdB64, + 'rawId': credIdB64, + 'type': 'public-key', + 'response': { + 'clientDataJSON': clientDataJsonB64, + 'authenticatorData': _base64UrlEncode(authdataBytes), + 'signature': _base64UrlEncode(sigBytes), + if (userIdBytes.isNotEmpty) + 'userHandle': _base64UrlEncode(userIdBytes), + }, + 'clientExtensionResults': + {}, // Required by PasskeyGetResult.fromJson + 'authenticatorAttachment': 'cross-platform', + }; + + return jsonEncode(responseMap); + }); + } finally { + b.fidoDevClose(dev); + b.fidoDevFree(devPtr); + b.fidoAssertFree(assertPtr); + _freeDevInfoList(b, devInfoList); + calloc.free(devPtr); + calloc.free(assertPtr); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /// Discovers connected FIDO2 devices and opens the first one found. + /// + /// Returns a [_DeviceResult] containing the opened device pointer and the + /// device info list (which the caller must free). + _DeviceResult _discoverAndOpenDevice(LibFido2Bindings b) { + final devInfoList = b.fidoDevInfoNew(_maxDevices); + final found = calloc(); + + try { + final manifestRc = b.fidoDevInfoManifest(devInfoList, _maxDevices, found); + if (manifestRc != fidoOk) { + throw const PasskeyNotSupportedException( + 'Failed to discover FIDO2 devices.', + ); + } + + if (found.value == 0) { + throw const PasskeyNotSupportedException( + 'No FIDO2 security key detected. ' + 'Please insert a FIDO2 USB key.', + ); + } + + // Get the path of the first device. + final entry = b.fidoDevInfoPtr(devInfoList, 0); + final path = b.fidoDevInfoPath(entry); + + // Open the device. + final dev = b.fidoDevNew(); + final openRc = b.fidoDevOpen(dev, path); + if (openRc != fidoOk) { + // Free the device handle on open failure. + final devPtr = calloc(); + devPtr.value = dev; + b.fidoDevFree(devPtr); + calloc.free(devPtr); + throw PasskeyNotSupportedException( + 'Failed to open FIDO2 device (error: $openRc).', + ); + } + + return _DeviceResult(device: dev, devInfoList: devInfoList); + } finally { + calloc.free(found); + } + } + + /// Frees the device info list allocated by [_discoverAndOpenDevice]. + void _freeDevInfoList(LibFido2Bindings b, Pointer devInfoList) { + final listPtr = calloc(); + listPtr.value = devInfoList; + b.fidoDevInfoFree(listPtr, _maxDevices); + calloc.free(listPtr); + } + + /// Checks a libfido2 return code and throws the appropriate + /// [PasskeyException] if it indicates an error. + void _checkFido(int rc, String operation, bool isRegistration) { + if (rc == fidoOk) return; + throw _mapLibFido2Error(rc, isRegistration); + } + + /// Maps a libfido2 error code to the appropriate [PasskeyException]. + static PasskeyException _mapLibFido2Error( + int errorCode, + bool isRegistration, + ) { + switch (errorCode) { + case fidoErrNotAllowed: + return const PasskeyCancelledException( + 'Operation not allowed or cancelled by user.', + ); + case fidoErrActionTimeout: + return const PasskeyCancelledException('Operation timed out.'); + case fidoErrPinRequired: + return const PasskeyAssertionFailedException( + 'PIN required but not provided.', + ); + case fidoErrUvBlocked: + return const PasskeyAssertionFailedException( + 'User verification blocked.', + ); + default: + if (isRegistration) { + return PasskeyRegistrationFailedException( + 'libfido2 error code: $errorCode', + ); + } + return PasskeyAssertionFailedException( + 'libfido2 error code: $errorCode', + ); + } + } + + /// Maps a WebAuthn `userVerification` preference string to a libfido2 UV + /// option constant. + static int _parseUserVerification(String? uv) { + switch (uv) { + case 'required': + return fidoOptTrue; + case 'preferred': + return fidoOptTrue; + case 'discouraged': + return fidoOptFalse; + default: + return fidoOptOmit; + } + } + + /// Constructs a `clientDataJSON` string per the WebAuthn specification. + static String _buildClientDataJson({ + required String type, + required String challenge, + required String origin, + }) { + return jsonEncode({ + 'type': type, + 'challenge': challenge, + 'origin': origin, + 'crossOrigin': false, + }); + } + + /// Copies [length] bytes from a native pointer into a Dart [Uint8List]. + static Uint8List _copyNativeBytes(Pointer ptr, int length) { + if (length <= 0 || ptr == nullptr) return Uint8List(0); + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = ptr[i]; + } + return bytes; + } + + /// Encodes bytes as base64url without padding. + static String _base64UrlEncode(Uint8List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); + } + + /// Decodes a base64url string (with or without padding) to bytes. + static Uint8List _base64UrlDecode(String input) { + // Add padding if needed. + var padded = input; + final remainder = padded.length % 4; + if (remainder != 0) { + padded = padded.padRight(padded.length + (4 - remainder), '='); + } + return base64Url.decode(padded); + } +} + +/// Result of device discovery containing the opened device handle and +/// the device info list that must be freed by the caller. +class _DeviceResult { + const _DeviceResult({required this.device, required this.devInfoList}); + + /// The opened FIDO2 device handle. + final Pointer device; + + /// The device info list (must be freed with `fido_dev_info_free`). + final Pointer devInfoList; +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform_stub.dart b/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform_stub.dart new file mode 100644 index 00000000000..01a42a2a332 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform_stub.dart @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Web stub — LinuxWebAuthnPlatform is not used on web. +// The real implementation lives in linux_webauthn_platform.dart. + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; + +/// Stub for web. Never instantiated on web (guarded by `zIsWeb` in addPlugin). +class LinuxWebAuthnPlatform implements WebAuthnCredentialPlatform { + @override + Future isPasskeySupported() => Future.value(false); + + @override + Future createCredential(String optionsJson) => + throw UnsupportedError('Not supported on web'); + + @override + Future getCredential(String optionsJson) => + throw UnsupportedError('Not supported on web'); +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/pigeon_webauthn_credential_platform.dart b/packages/auth/amplify_auth_cognito/lib/src/pigeon_webauthn_credential_platform.dart new file mode 100644 index 00000000000..1cca8858798 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/pigeon_webauthn_credential_platform.dart @@ -0,0 +1,111 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito/src/webauthn_bridge.g.dart'; +// ignore: implementation_imports +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter/services.dart'; + +/// Standard error codes used by native platform implementations to +/// communicate WebAuthn errors through the Pigeon bridge. +/// +/// Native Swift and Kotlin code should throw `PlatformException` with +/// one of these codes. The [PigeonWebAuthnCredentialPlatform] adapter +/// maps them to the corresponding [PasskeyException] subtypes. +abstract final class WebAuthnErrorCodes { + /// The user cancelled the passkey ceremony. + static const String cancelled = 'cancelled'; + + /// Passkeys are not supported on this platform/device. + static const String notSupported = 'notSupported'; + + /// Passkey registration (credential creation) failed. + static const String registrationFailed = 'registrationFailed'; + + /// Passkey assertion (credential retrieval) failed. + static const String assertionFailed = 'assertionFailed'; + + /// The relying party ID does not match the expected domain. + static const String rpMismatch = 'rpMismatch'; +} + +/// {@template amplify_auth_cognito.pigeon_webauthn_credential_platform} +/// Dart adapter that delegates [WebAuthnCredentialPlatform] calls to the +/// Pigeon-generated [WebAuthnBridgeApi] and maps [PlatformException] errors +/// to the appropriate [PasskeyException] subtypes. +/// {@endtemplate} +class PigeonWebAuthnCredentialPlatform implements WebAuthnCredentialPlatform { + /// {@macro amplify_auth_cognito.pigeon_webauthn_credential_platform} + const PigeonWebAuthnCredentialPlatform(this._bridge); + + final WebAuthnBridgeApi _bridge; + + @override + Future createCredential(String optionsJson) async { + try { + return await _bridge.createCredential(optionsJson); + } on PlatformException catch (e) { + throw _mapError(e, _defaultCreateError); + } + } + + @override + Future getCredential(String optionsJson) async { + try { + return await _bridge.getCredential(optionsJson); + } on PlatformException catch (e) { + throw _mapError(e, _defaultGetError); + } + } + + @override + Future isPasskeySupported() async { + try { + return await _bridge.isPasskeySupported(); + } on PlatformException catch (e) { + throw PasskeyException( + e.message ?? 'Failed to check passkey support', + underlyingException: e, + ); + } + } + + /// Maps a [PlatformException] to the corresponding [PasskeyException] + /// subtype based on the error code. + /// + /// [defaultError] is called when the error code does not match any known + /// code — it allows different defaults for create vs get operations. + static PasskeyException _mapError( + PlatformException e, + PasskeyException Function(PlatformException) defaultError, + ) { + final message = e.message ?? 'Unknown passkey error'; + switch (e.code) { + case WebAuthnErrorCodes.cancelled: + return PasskeyCancelledException(message, underlyingException: e); + case WebAuthnErrorCodes.notSupported: + return PasskeyNotSupportedException(message, underlyingException: e); + case WebAuthnErrorCodes.rpMismatch: + return PasskeyRpMismatchException(message, underlyingException: e); + default: + return defaultError(e); + } + } + + static PasskeyRegistrationFailedException _defaultCreateError( + PlatformException e, + ) { + return PasskeyRegistrationFailedException( + e.message ?? 'Passkey registration failed', + underlyingException: e, + ); + } + + static PasskeyAssertionFailedException _defaultGetError(PlatformException e) { + return PasskeyAssertionFailedException( + e.message ?? 'Passkey assertion failed', + underlyingException: e, + ); + } +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/web_io_stub.dart b/packages/auth/amplify_auth_cognito/lib/src/web_io_stub.dart new file mode 100644 index 00000000000..6c9beed74c7 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/web_io_stub.dart @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: public_member_api_docs, avoid_classes_with_only_static_members + +// Web stub for dart:io — Platform is not available on web. +// auth_plugin_impl.dart guards all Platform.isX calls with zIsWeb checks, +// so these are never actually called on web. + +abstract class Platform { + static bool get isAndroid => false; + static bool get isIOS => false; + static bool get isMacOS => false; + static bool get isWindows => false; + static bool get isLinux => false; +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/webauthn_bridge.g.dart b/packages/auth/amplify_auth_cognito/lib/src/webauthn_bridge.g.dart new file mode 100644 index 00000000000..6b1ea135372 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/webauthn_bridge.g.dart @@ -0,0 +1,151 @@ +// +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Autogenerated from Pigeon (v26.1.10), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +/// Pigeon bridge for WebAuthn/passkey operations. +/// +/// Platform implementations (iOS/macOS via ASAuthorizationController, +/// Android via CredentialManager) handle the native ceremony and return +/// JSON-serialized W3C WebAuthn Level 3 response objects. +class WebAuthnBridgeApi { + /// Constructor for [WebAuthnBridgeApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WebAuthnBridgeApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Creates a new passkey credential on the device. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions`. + /// Returns a JSON-serialized `RegistrationResponseJSON`. + Future createCredential(String optionsJson) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.createCredential$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [optionsJson], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + )!; + return pigeonVar_replyValue as String; + } + + /// Retrieves a passkey credential assertion for authentication. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions`. + /// Returns a JSON-serialized `AuthenticationResponseJSON`. + Future getCredential(String optionsJson) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.getCredential$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [optionsJson], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + )!; + return pigeonVar_replyValue as String; + } + + /// Returns whether the current device/platform supports passkeys. + Future isPasskeySupported() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.isPasskeySupported$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + )!; + return pigeonVar_replyValue as bool; + } +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/windows/webauthn_bindings.dart b/packages/auth/amplify_auth_cognito/lib/src/windows/webauthn_bindings.dart new file mode 100644 index 00000000000..0fa6009ee14 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/windows/webauthn_bindings.dart @@ -0,0 +1,394 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: non_constant_identifier_names, constant_identifier_names, public_member_api_docs + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +/// HRESULT success code. +const int S_OK = 0; + +/// The user cancelled the operation. +const int NTE_USER_CANCELLED = 0x80090036; + +/// The specified item was not found. +const int NTE_NOT_FOUND = 0x80090011; + +/// An invalid parameter was passed to the function. +const int NTE_INVALID_PARAMETER = 0x80090027; + +/// Minimum API version required for JSON pass-through support. +const int WEBAUTHN_API_VERSION_4 = 4; + +/// Current API version for MakeCredential options struct (version 7). +const int WEBAUTHN_MAKE_CREDENTIAL_OPTIONS_VERSION = 7; + +/// Current API version for GetAssertion options struct (version 7). +const int WEBAUTHN_GET_ASSERTION_OPTIONS_VERSION = 7; + +/// Version for RP entity information struct. +const int WEBAUTHN_RP_ENTITY_INFORMATION_VERSION = 1; + +/// Version for user entity information struct. +const int WEBAUTHN_USER_ENTITY_INFORMATION_VERSION = 1; + +/// Version for client data struct. +const int WEBAUTHN_CLIENT_DATA_VERSION = 1; + +/// Version for COSE credential parameter struct. +const int WEBAUTHN_COSE_CREDENTIAL_PARAMETER_VERSION = 1; + +/// SHA-256 hash algorithm identifier. +const String WEBAUTHN_HASH_ALGORITHM_SHA_256 = 'SHA-256'; + +/// Public key credential type string. +const String WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY = 'public-key'; + +// --- Native function typedefs --- + +/// `DWORD WebAuthNGetApiVersionNumber(void)` +typedef WebAuthNGetApiVersionNumberNative = Uint32 Function(); +typedef WebAuthNGetApiVersionNumberDart = int Function(); + +/// `HRESULT WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable( +/// BOOL *pbIsUserVerifyingPlatformAuthenticatorAvailable +/// )` +typedef WebAuthNIsUserVerifyingPlatformAuthenticatorAvailableNative = + Int32 Function(Pointer pbIsAvailable); +typedef WebAuthNIsUserVerifyingPlatformAuthenticatorAvailableDart = + int Function(Pointer pbIsAvailable); + +/// `HRESULT WebAuthNAuthenticatorMakeCredential( +/// HWND hWnd, +/// PCWEBAUTHN_RP_ENTITY_INFORMATION pRpInformation, +/// PCWEBAUTHN_USER_ENTITY_INFORMATION pUserInformation, +/// PCWEBAUTHN_COSE_CREDENTIAL_PARAMETERS pPubKeyCredParams, +/// PCWEBAUTHN_CLIENT_DATA pWebAuthNClientData, +/// PCWEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS pWebAuthNMakeCredentialOptions, +/// PWEBAUTHN_CREDENTIAL_ATTESTATION *ppWebAuthNCredentialAttestation +/// )` +typedef WebAuthNAuthenticatorMakeCredentialNative = + Int32 Function( + IntPtr hWnd, + Pointer rpInfo, + Pointer userInfo, + Pointer pubKeyCredParams, + Pointer clientData, + Pointer options, + Pointer ppResult, + ); +typedef WebAuthNAuthenticatorMakeCredentialDart = + int Function( + int hWnd, + Pointer rpInfo, + Pointer userInfo, + Pointer pubKeyCredParams, + Pointer clientData, + Pointer options, + Pointer ppResult, + ); + +/// `HRESULT WebAuthNGetAssertion( +/// HWND hWnd, +/// LPCWSTR pwszRpId, +/// PCWEBAUTHN_CLIENT_DATA pWebAuthNClientData, +/// PCWEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS pWebAuthNGetAssertionOptions, +/// PWEBAUTHN_ASSERTION *ppWebAuthNAssertion +/// )` +typedef WebAuthNGetAssertionNative = + Int32 Function( + IntPtr hWnd, + Pointer rpId, + Pointer clientData, + Pointer options, + Pointer ppResult, + ); +typedef WebAuthNGetAssertionDart = + int Function( + int hWnd, + Pointer rpId, + Pointer clientData, + Pointer options, + Pointer ppResult, + ); + +/// `void WebAuthNFreeCredentialAttestation( +/// PWEBAUTHN_CREDENTIAL_ATTESTATION pWebAuthNCredentialAttestation +/// )` +typedef WebAuthNFreeCredentialAttestationNative = + Void Function(Pointer pAttestation); +typedef WebAuthNFreeCredentialAttestationDart = + void Function(Pointer pAttestation); + +/// `void WebAuthNFreeAssertion(PWEBAUTHN_ASSERTION pWebAuthNAssertion)` +typedef WebAuthNFreeAssertionNative = Void Function(Pointer pAssertion); +typedef WebAuthNFreeAssertionDart = void Function(Pointer pAssertion); + +/// `HWND GetActiveWindow(void)` from user32.dll +typedef GetActiveWindowNative = IntPtr Function(); +typedef GetActiveWindowDart = int Function(); + +/// {@template amplify_auth_cognito.webauthn_bindings} +/// FFI bindings to `webauthn.dll` and `user32.dll` for Windows WebAuthn +/// (Windows Hello FIDO2) API access. +/// +/// Wraps function lookups in lazy final fields for testability. The +/// constructor accepts optional [DynamicLibrary] parameters to allow +/// injection in tests. +/// {@endtemplate} +class WebAuthnBindings { + /// {@macro amplify_auth_cognito.webauthn_bindings} + WebAuthnBindings({DynamicLibrary? webauthnLib, DynamicLibrary? user32Lib}) + : _webauthn = webauthnLib ?? DynamicLibrary.open('webauthn.dll'), + _user32 = user32Lib ?? DynamicLibrary.open('user32.dll'); + + final DynamicLibrary _webauthn; + final DynamicLibrary _user32; + + /// Returns the API version number supported by the platform. + late final WebAuthNGetApiVersionNumberDart getApiVersionNumber = _webauthn + .lookupFunction< + WebAuthNGetApiVersionNumberNative, + WebAuthNGetApiVersionNumberDart + >('WebAuthNGetApiVersionNumber'); + + /// Checks whether a user-verifying platform authenticator is available. + /// + /// Writes a boolean value (as `Int32`) to the provided pointer. + /// Returns an HRESULT indicating success or failure. + late final WebAuthNIsUserVerifyingPlatformAuthenticatorAvailableDart + isUserVerifyingPlatformAuthenticatorAvailable = _webauthn + .lookupFunction< + WebAuthNIsUserVerifyingPlatformAuthenticatorAvailableNative, + WebAuthNIsUserVerifyingPlatformAuthenticatorAvailableDart + >('WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable'); + + /// Initiates a WebAuthn credential creation (registration) ceremony. + /// + /// Returns an HRESULT. On success, `ppResult` points to a + /// `WEBAUTHN_CREDENTIAL_ATTESTATION` struct that must be freed with + /// [freeCredentialAttestation]. + late final WebAuthNAuthenticatorMakeCredentialDart makeCredential = _webauthn + .lookupFunction< + WebAuthNAuthenticatorMakeCredentialNative, + WebAuthNAuthenticatorMakeCredentialDart + >('WebAuthNAuthenticatorMakeCredential'); + + /// Initiates a WebAuthn assertion (authentication) ceremony. + /// + /// Returns an HRESULT. On success, `ppResult` points to a + /// `WEBAUTHN_ASSERTION` struct that must be freed with [freeAssertion]. + late final WebAuthNGetAssertionDart getAssertion = _webauthn + .lookupFunction( + 'WebAuthNGetAssertion', + ); + + /// Frees a `WEBAUTHN_CREDENTIAL_ATTESTATION` struct returned by + /// [makeCredential]. + late final WebAuthNFreeCredentialAttestationDart freeCredentialAttestation = + _webauthn.lookupFunction< + WebAuthNFreeCredentialAttestationNative, + WebAuthNFreeCredentialAttestationDart + >('WebAuthNFreeCredentialAttestation'); + + /// Frees a `WEBAUTHN_ASSERTION` struct returned by [getAssertion]. + late final WebAuthNFreeAssertionDart freeAssertion = _webauthn + .lookupFunction( + 'WebAuthNFreeAssertion', + ); + + /// Returns the handle to the active window (from `user32.dll`). + /// + /// Used to obtain the `HWND` parameter required by + /// [makeCredential] and [getAssertion]. + late final GetActiveWindowDart getActiveWindow = _user32 + .lookupFunction( + 'GetActiveWindow', + ); +} + +// --------------------------------------------------------------------------- +// Struct layout helpers +// +// The Windows WebAuthn structs are large and version-dependent. Rather than +// defining full `Struct` subclasses (which would require correctly aligning +// 20+ fields including nested pointers across versions), we allocate raw +// memory and write fields at known byte offsets for the JSON pass-through +// fields we need. +// +// All sizes assume 64-bit Windows (LLP64: int=4, pointer=8, DWORD=4). +// --------------------------------------------------------------------------- + +/// Byte offsets within `WEBAUTHN_RP_ENTITY_INFORMATION` (version 1). +/// +/// Layout: +/// ``` +/// DWORD dwVersion; // offset 0, size 4 +/// // 4 bytes padding +/// PCWSTR pwszId; // offset 8, size 8 +/// PCWSTR pwszName; // offset 16, size 8 +/// PCWSTR pwszIcon; // offset 24, size 8 +/// ``` +abstract final class RpEntityOffsets { + static const int dwVersion = 0; + static const int pwszId = 8; + static const int pwszName = 16; + static const int pwszIcon = 24; + static const int structSize = 32; +} + +/// Byte offsets within `WEBAUTHN_USER_ENTITY_INFORMATION` (version 1). +/// +/// Layout: +/// ``` +/// DWORD dwVersion; // offset 0, size 4 +/// DWORD cbId; // offset 4, size 4 +/// PBYTE pbId; // offset 8, size 8 +/// PCWSTR pwszName; // offset 16, size 8 +/// PCWSTR pwszIcon; // offset 24, size 8 +/// PCWSTR pwszDisplayName; // offset 32, size 8 +/// ``` +abstract final class UserEntityOffsets { + static const int dwVersion = 0; + static const int cbId = 4; + static const int pbId = 8; + static const int pwszName = 16; + static const int pwszIcon = 24; + static const int pwszDisplayName = 32; + static const int structSize = 40; +} + +/// Byte offsets within `WEBAUTHN_COSE_CREDENTIAL_PARAMETER` (version 1). +/// +/// Layout: +/// ``` +/// DWORD dwVersion; // offset 0, size 4 +/// // 4 bytes padding +/// PCWSTR pwszCredentialType; // offset 8, size 8 +/// LONG lAlg; // offset 16, size 4 +/// // 4 bytes padding (to align struct to 8) +/// ``` +abstract final class CoseCredentialParameterOffsets { + static const int dwVersion = 0; + static const int pwszCredentialType = 8; + static const int lAlg = 16; + static const int structSize = 24; +} + +/// Byte offsets within `WEBAUTHN_COSE_CREDENTIAL_PARAMETERS`. +/// +/// Layout: +/// ``` +/// DWORD cCredentialParameters; // offset 0, size 4 +/// // 4 bytes padding +/// PWEBAUTHN_COSE_CREDENTIAL_PARAMETER pCredParams; // offset 8, size 8 +/// ``` +abstract final class CoseCredentialParametersOffsets { + static const int cCredentialParameters = 0; + static const int pCredentialParameters = 8; + static const int structSize = 16; +} + +/// Byte offsets within `WEBAUTHN_CLIENT_DATA` (version 1). +/// +/// Layout: +/// ``` +/// DWORD dwVersion; // offset 0, size 4 +/// DWORD cbClientDataJSON; // offset 4, size 4 +/// PBYTE pbClientDataJSON; // offset 8, size 8 +/// PCWSTR pwszHashAlgId; // offset 16, size 8 +/// ``` +abstract final class ClientDataOffsets { + static const int dwVersion = 0; + static const int cbClientDataJSON = 4; + static const int pbClientDataJSON = 8; + static const int pwszHashAlgId = 16; + static const int structSize = 24; +} + +/// Size and key offsets for `WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS` +/// version 7 struct. +/// +/// This is a large struct (~200+ bytes). We only define offsets for fields +/// we actively set: the version, timeout, and the JSON pass-through fields +/// added in version 5. +/// +/// The JSON pass-through fields are at the END of the struct (after all +/// v1-v4 fields). Exact offsets are calculated from the Windows SDK +/// `webauthn.h` header for the 64-bit ABI. +abstract final class MakeCredentialOptionsOffsets { + /// `DWORD dwVersion` at offset 0. + static const int dwVersion = 0; + + /// `DWORD dwTimeoutMilliseconds` at offset 4. + static const int dwTimeoutMilliseconds = 4; + + // Version 5 JSON pass-through fields (appended after v4 fields): + + /// `DWORD cbPublicKeyCredentialCreationOptionsJSON` — byte length of JSON. + static const int cbJsonOptions = 192; + + /// `PBYTE pbPublicKeyCredentialCreationOptionsJSON` — pointer to JSON bytes. + static const int pbJsonOptions = 200; + + /// Total struct size for version 7 (rounded up to pointer alignment). + static const int structSize = 208; +} + +/// Size and key offsets for `WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS` +/// version 7 struct. +/// +/// Similar to [MakeCredentialOptionsOffsets], we only populate the version, +/// timeout, and JSON pass-through fields (added in version 6). +abstract final class GetAssertionOptionsOffsets { + /// `DWORD dwVersion` at offset 0. + static const int dwVersion = 0; + + /// `DWORD dwTimeoutMilliseconds` at offset 4. + static const int dwTimeoutMilliseconds = 4; + + // Version 6 JSON pass-through fields: + + /// `DWORD cbPublicKeyCredentialRequestOptionsJSON` — byte length of JSON. + static const int cbJsonOptions = 160; + + /// `PBYTE pbPublicKeyCredentialRequestOptionsJSON` — pointer to JSON bytes. + static const int pbJsonOptions = 168; + + /// Total struct size for version 7 (rounded up to pointer alignment). + static const int structSize = 176; +} + +/// Key offsets within `WEBAUTHN_CREDENTIAL_ATTESTATION` for reading the +/// JSON registration response. +/// +/// The struct has many fields; we only need the JSON response fields +/// added in version 4: +/// - `cbRegistrationResponseJSON` (DWORD) +/// - `pbRegistrationResponseJSON` (PBYTE) +/// +/// These are at the end of the v3 struct + extensions. +abstract final class CredentialAttestationOffsets { + /// `DWORD cbRegistrationResponseJSON` — byte length of JSON response. + static const int cbRegistrationResponseJSON = 152; + + /// `PBYTE pbRegistrationResponseJSON` — pointer to JSON response bytes. + static const int pbRegistrationResponseJSON = 160; +} + +/// Key offsets within `WEBAUTHN_ASSERTION` for reading the JSON +/// authentication response. +/// +/// - `cbAuthenticationResponseJSON` (DWORD) +/// - `pbAuthenticationResponseJSON` (PBYTE) +/// +/// Added in version 3 of the assertion struct. +abstract final class AssertionOffsets { + /// `DWORD cbAuthenticationResponseJSON` — byte length of JSON response. + static const int cbAuthenticationResponseJSON = 104; + + /// `PBYTE pbAuthenticationResponseJSON` — pointer to JSON response bytes. + static const int pbAuthenticationResponseJSON = 112; +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform.dart b/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform.dart new file mode 100644 index 00000000000..d72943e4ff1 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform.dart @@ -0,0 +1,472 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:amplify_auth_cognito/src/windows/webauthn_bindings.dart'; +// ignore: implementation_imports +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:ffi/ffi.dart'; + +/// {@template amplify_auth_cognito.windows_webauthn_platform} +/// Windows implementation of [WebAuthnCredentialPlatform] using the +/// Windows Hello FIDO2 API (`webauthn.dll`) via FFI. +/// +/// Uses the JSON pass-through mode available in Windows WebAuthn API +/// version 4+ to avoid manually constructing the full C struct hierarchy. +/// The JSON options from Cognito are passed directly to the native API, +/// and the JSON response is read directly from the result struct. +/// {@endtemplate} +class WindowsWebAuthnPlatform implements WebAuthnCredentialPlatform { + /// {@macro amplify_auth_cognito.windows_webauthn_platform} + /// + /// Accepts an optional [WebAuthnBindings] for testability. + WindowsWebAuthnPlatform({WebAuthnBindings? bindings}) + : _bindings = bindings ?? WebAuthnBindings(); + + final WebAuthnBindings _bindings; + + /// Cached API version number. + late final int _apiVersion = _bindings.getApiVersionNumber(); + + @override + Future isPasskeySupported() async { + try { + // Check if the API version supports JSON pass-through. + if (_apiVersion < WEBAUTHN_API_VERSION_4) { + return false; + } + + final pbIsAvailable = calloc(); + try { + final hr = _bindings.isUserVerifyingPlatformAuthenticatorAvailable( + pbIsAvailable, + ); + if (hr != S_OK) { + return false; + } + return pbIsAvailable.value != 0; + } finally { + calloc.free(pbIsAvailable); + } + } on Exception { + // If the DLL cannot be loaded or any other error occurs, + // passkeys are not supported. + return false; + } + } + + @override + Future createCredential(String optionsJson) async { + final hWnd = _bindings.getActiveWindow(); + if (hWnd == 0) { + throw const PasskeyRegistrationFailedException( + 'No active window available for WebAuthn ceremony', + recoverySuggestion: + 'Ensure the application window is in the foreground.', + ); + } + + if (_apiVersion < WEBAUTHN_API_VERSION_4) { + throw const PasskeyNotSupportedException( + 'Windows WebAuthn API version 4+ is required for passkey support', + ); + } + + final optionsMap = json.decode(optionsJson) as Map; + + // Extract required fields for the C structs. + final rp = optionsMap['rp'] as Map; + final rpId = rp['id'] as String? ?? ''; + final rpName = rp['name'] as String? ?? ''; + final user = optionsMap['user'] as Map; + final userName = user['name'] as String? ?? ''; + final userDisplayName = user['displayName'] as String? ?? ''; + final userId = user['id'] as String? ?? ''; + final userIdBytes = utf8.encode(userId); + final pubKeyCredParams = + (optionsMap['pubKeyCredParams'] as List?) ?? []; + + // Encode the full options JSON as UTF-8 for pass-through. + final optionsJsonBytes = utf8.encode(optionsJson); + + // Allocate a dummy client data (required parameter, but JSON + // pass-through mode uses the options JSON directly). + final dummyClientData = utf8.encode('{}'); + + final arena = Arena(); + Pointer pAttestation = nullptr; + try { + // --- RP Entity --- + final rpInfo = arena(RpEntityOffsets.structSize); + _zeroMemory(rpInfo, RpEntityOffsets.structSize); + rpInfo.cast().value = WEBAUTHN_RP_ENTITY_INFORMATION_VERSION; + _writePointerAt( + rpInfo, + RpEntityOffsets.pwszId, + rpId.toNativeUtf16(allocator: arena).cast(), + ); + _writePointerAt( + rpInfo, + RpEntityOffsets.pwszName, + rpName.toNativeUtf16(allocator: arena).cast(), + ); + + // --- User Entity --- + final userInfo = arena(UserEntityOffsets.structSize); + _zeroMemory(userInfo, UserEntityOffsets.structSize); + userInfo.cast().value = WEBAUTHN_USER_ENTITY_INFORMATION_VERSION; + _writeUint32At(userInfo, UserEntityOffsets.cbId, userIdBytes.length); + final pbUserId = arena(userIdBytes.length); + pbUserId.asTypedList(userIdBytes.length).setAll(0, userIdBytes); + _writePointerAt(userInfo, UserEntityOffsets.pbId, pbUserId); + _writePointerAt( + userInfo, + UserEntityOffsets.pwszName, + userName.toNativeUtf16(allocator: arena).cast(), + ); + _writePointerAt( + userInfo, + UserEntityOffsets.pwszDisplayName, + userDisplayName.toNativeUtf16(allocator: arena).cast(), + ); + + // --- COSE Credential Parameters --- + final paramCount = pubKeyCredParams.length; + final credParamsArray = paramCount > 0 + ? arena(CoseCredentialParameterOffsets.structSize * paramCount) + : nullptr.cast(); + final credTypeStr = WEBAUTHN_CREDENTIAL_TYPE_PUBLIC_KEY.toNativeUtf16( + allocator: arena, + ); + for (var i = 0; i < paramCount; i++) { + final param = pubKeyCredParams[i] as Map; + final alg = param['alg'] as int? ?? -7; // ES256 default + final offset = i * CoseCredentialParameterOffsets.structSize; + final entry = credParamsArray + offset; + _zeroMemory(entry, CoseCredentialParameterOffsets.structSize); + entry.cast().value = WEBAUTHN_COSE_CREDENTIAL_PARAMETER_VERSION; + _writePointerAt( + entry, + CoseCredentialParameterOffsets.pwszCredentialType, + credTypeStr.cast(), + ); + _writeInt32At(entry, CoseCredentialParameterOffsets.lAlg, alg); + } + + final credParams = arena( + CoseCredentialParametersOffsets.structSize, + ); + _zeroMemory(credParams, CoseCredentialParametersOffsets.structSize); + credParams.cast().value = paramCount; + _writePointerAt( + credParams, + CoseCredentialParametersOffsets.pCredentialParameters, + credParamsArray, + ); + + // --- Client Data --- + final clientData = arena(ClientDataOffsets.structSize); + _zeroMemory(clientData, ClientDataOffsets.structSize); + clientData.cast().value = WEBAUTHN_CLIENT_DATA_VERSION; + _writeUint32At( + clientData, + ClientDataOffsets.cbClientDataJSON, + dummyClientData.length, + ); + final pbClientData = arena(dummyClientData.length); + pbClientData + .asTypedList(dummyClientData.length) + .setAll(0, dummyClientData); + _writePointerAt( + clientData, + ClientDataOffsets.pbClientDataJSON, + pbClientData, + ); + _writePointerAt( + clientData, + ClientDataOffsets.pwszHashAlgId, + WEBAUTHN_HASH_ALGORITHM_SHA_256.toNativeUtf16(allocator: arena).cast(), + ); + + // --- MakeCredential Options (with JSON pass-through) --- + final options = arena(MakeCredentialOptionsOffsets.structSize); + _zeroMemory(options, MakeCredentialOptionsOffsets.structSize); + options.cast().value = WEBAUTHN_MAKE_CREDENTIAL_OPTIONS_VERSION; + _writeUint32At( + options, + MakeCredentialOptionsOffsets.dwTimeoutMilliseconds, + 120000, + ); + + // JSON pass-through fields + _writeUint32At( + options, + MakeCredentialOptionsOffsets.cbJsonOptions, + optionsJsonBytes.length, + ); + final pbJsonOptions = arena(optionsJsonBytes.length); + pbJsonOptions + .asTypedList(optionsJsonBytes.length) + .setAll(0, optionsJsonBytes); + _writePointerAt( + options, + MakeCredentialOptionsOffsets.pbJsonOptions, + pbJsonOptions, + ); + + // --- Call MakeCredential --- + final ppResult = arena(); + final hr = _bindings.makeCredential( + hWnd, + rpInfo.cast(), + userInfo.cast(), + credParams.cast(), + clientData.cast(), + options.cast(), + ppResult, + ); + + if (hr != S_OK) { + _throwHResultError(hr, isRegistration: true); + } + + pAttestation = ppResult.value; + + // Read JSON response from the attestation result struct. + final cbJson = + (pAttestation.cast() + + CredentialAttestationOffsets.cbRegistrationResponseJSON) + .cast() + .value; + final pbJson = _readPointerAt( + pAttestation.cast(), + CredentialAttestationOffsets.pbRegistrationResponseJSON, + ); + + if (cbJson == 0 || pbJson == nullptr) { + throw const PasskeyRegistrationFailedException( + 'Windows WebAuthn returned empty registration response', + ); + } + + final jsonString = utf8.decode( + Uint8List.fromList(pbJson.cast().asTypedList(cbJson)), + ); + + // Ensure clientExtensionResults is present (required by PasskeyCreateResult.fromJson) + return _ensureClientExtensionResults(jsonString); + } finally { + // Free attestation struct if it was allocated by the API. + if (pAttestation != nullptr) { + _bindings.freeCredentialAttestation(pAttestation); + } + arena.releaseAll(); + } + } + + @override + Future getCredential(String optionsJson) async { + final hWnd = _bindings.getActiveWindow(); + if (hWnd == 0) { + throw const PasskeyAssertionFailedException( + 'No active window available for WebAuthn ceremony', + recoverySuggestion: + 'Ensure the application window is in the foreground.', + ); + } + + if (_apiVersion < WEBAUTHN_API_VERSION_4) { + throw const PasskeyNotSupportedException( + 'Windows WebAuthn API version 4+ is required for passkey support', + ); + } + + final optionsMap = json.decode(optionsJson) as Map; + final rpId = optionsMap['rpId'] as String? ?? ''; + + // Encode the full options JSON as UTF-8 for pass-through. + final optionsJsonBytes = utf8.encode(optionsJson); + + // Dummy client data (required parameter). + final dummyClientData = utf8.encode('{}'); + + final arena = Arena(); + Pointer pAssertion = nullptr; + try { + // --- Client Data --- + final clientData = arena(ClientDataOffsets.structSize); + _zeroMemory(clientData, ClientDataOffsets.structSize); + clientData.cast().value = WEBAUTHN_CLIENT_DATA_VERSION; + _writeUint32At( + clientData, + ClientDataOffsets.cbClientDataJSON, + dummyClientData.length, + ); + final pbClientData = arena(dummyClientData.length); + pbClientData + .asTypedList(dummyClientData.length) + .setAll(0, dummyClientData); + _writePointerAt( + clientData, + ClientDataOffsets.pbClientDataJSON, + pbClientData, + ); + _writePointerAt( + clientData, + ClientDataOffsets.pwszHashAlgId, + WEBAUTHN_HASH_ALGORITHM_SHA_256.toNativeUtf16(allocator: arena).cast(), + ); + + // --- GetAssertion Options (with JSON pass-through) --- + final options = arena(GetAssertionOptionsOffsets.structSize); + _zeroMemory(options, GetAssertionOptionsOffsets.structSize); + options.cast().value = WEBAUTHN_GET_ASSERTION_OPTIONS_VERSION; + _writeUint32At( + options, + GetAssertionOptionsOffsets.dwTimeoutMilliseconds, + 120000, + ); + + // JSON pass-through fields + _writeUint32At( + options, + GetAssertionOptionsOffsets.cbJsonOptions, + optionsJsonBytes.length, + ); + final pbJsonOptions = arena(optionsJsonBytes.length); + pbJsonOptions + .asTypedList(optionsJsonBytes.length) + .setAll(0, optionsJsonBytes); + _writePointerAt( + options, + GetAssertionOptionsOffsets.pbJsonOptions, + pbJsonOptions, + ); + + // --- Call GetAssertion --- + final ppResult = arena(); + final rpIdNative = rpId.toNativeUtf16(allocator: arena); + final hr = _bindings.getAssertion( + hWnd, + rpIdNative, + clientData.cast(), + options.cast(), + ppResult, + ); + + if (hr != S_OK) { + _throwHResultError(hr, isRegistration: false); + } + + pAssertion = ppResult.value; + + // Read JSON response from the assertion result struct. + final cbJson = + (pAssertion.cast() + + AssertionOffsets.cbAuthenticationResponseJSON) + .cast() + .value; + final pbJson = _readPointerAt( + pAssertion.cast(), + AssertionOffsets.pbAuthenticationResponseJSON, + ); + + if (cbJson == 0 || pbJson == nullptr) { + throw const PasskeyAssertionFailedException( + 'Windows WebAuthn returned empty authentication response', + ); + } + + final jsonString = utf8.decode( + Uint8List.fromList(pbJson.cast().asTypedList(cbJson)), + ); + + // Ensure clientExtensionResults is present (required by PasskeyGetResult.fromJson) + return _ensureClientExtensionResults(jsonString); + } finally { + // Free assertion struct if it was allocated by the API. + if (pAssertion != nullptr) { + _bindings.freeAssertion(pAssertion); + } + arena.releaseAll(); + } + } + + /// Maps a Windows HRESULT error code to the appropriate + /// [PasskeyException] subtype. + Never _throwHResultError(int hr, {required bool isRegistration}) { + final hexCode = '0x${hr.toRadixString(16).padLeft(8, '0')}'; + final message = 'Windows WebAuthn error: $hexCode'; + + switch (hr) { + case NTE_USER_CANCELLED: + throw PasskeyCancelledException(message); + case NTE_NOT_FOUND: + throw PasskeyAssertionFailedException(message); + case NTE_INVALID_PARAMETER: + if (isRegistration) { + throw PasskeyRegistrationFailedException(message); + } + throw PasskeyAssertionFailedException(message); + default: + if (isRegistration) { + throw PasskeyRegistrationFailedException(message); + } + throw PasskeyAssertionFailedException(message); + } + } + + // --------------------------------------------------------------------------- + // Memory helpers + // --------------------------------------------------------------------------- + + /// Writes a 32-bit unsigned integer at [offset] bytes from [base]. + static void _writeUint32At(Pointer base, int offset, int value) { + (base + offset).cast().value = value; + } + + /// Writes a 32-bit signed integer at [offset] bytes from [base]. + static void _writeInt32At(Pointer base, int offset, int value) { + (base + offset).cast().value = value; + } + + /// Writes a pointer value at [offset] bytes from [base]. + static void _writePointerAt(Pointer base, int offset, Pointer value) { + (base + offset).cast().value = value; + } + + /// Reads a pointer value at [offset] bytes from [base]. + static Pointer _readPointerAt(Pointer base, int offset) { + return (base + offset).cast().value; + } + + /// Zeroes [size] bytes of memory starting at [base]. + static void _zeroMemory(Pointer base, int size) { + for (var i = 0; i < size; i++) { + (base + i).value = 0; + } + } + + /// Ensures that the JSON response contains a `clientExtensionResults` field. + /// + /// The Windows WebAuthn API v4+ may not include this field, but + /// PasskeyCreateResult.fromJson and PasskeyGetResult.fromJson require it + /// (non-nullable field). This function parses the JSON, adds the field if + /// missing, and returns the updated JSON string. + static String _ensureClientExtensionResults(String jsonString) { + try { + final jsonMap = json.decode(jsonString) as Map; + if (!jsonMap.containsKey('clientExtensionResults')) { + jsonMap['clientExtensionResults'] = {}; + } + return json.encode(jsonMap); + } on Object { + // If parsing fails, return original JSON (let caller handle the error) + return jsonString; + } + } +} diff --git a/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform_stub.dart b/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform_stub.dart new file mode 100644 index 00000000000..e0b218bd3e3 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform_stub.dart @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Web stub — WindowsWebAuthnPlatform is not used on web. +// The real implementation lives in windows_webauthn_platform.dart. + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; + +/// Stub for web. Never instantiated on web (guarded by `zIsWeb` in addPlugin). +class WindowsWebAuthnPlatform implements WebAuthnCredentialPlatform { + @override + Future isPasskeySupported() => Future.value(false); + + @override + Future createCredential(String optionsJson) => + throw UnsupportedError('Not supported on web'); + + @override + Future getCredential(String optionsJson) => + throw UnsupportedError('Not supported on web'); +} diff --git a/packages/auth/amplify_auth_cognito/pigeons/webauthn_bridge.dart b/packages/auth/amplify_auth_cognito/pigeons/webauthn_bridge.dart new file mode 100644 index 00000000000..c860f96c541 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/pigeons/webauthn_bridge.dart @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@ConfigurePigeon( + PigeonOptions( + copyrightHeader: '../../../tool/license.txt', + dartOut: 'lib/src/webauthn_bridge.g.dart', + kotlinOptions: KotlinOptions( + package: 'com.amazonaws.amplify.amplify_auth_cognito', + ), + kotlinOut: + 'android/src/main/kotlin/com/amazonaws/amplify/amplify_auth_cognito/pigeons/WebAuthnBridgePigeon.kt', + swiftOut: 'darwin/classes/pigeons/WebAuthnBridge.g.swift', + ), +) +library; + +import 'package:pigeon/pigeon.dart'; + +/// Pigeon bridge for WebAuthn/passkey operations. +/// +/// Platform implementations (iOS/macOS via ASAuthorizationController, +/// Android via CredentialManager) handle the native ceremony and return +/// JSON-serialized W3C WebAuthn Level 3 response objects. +@HostApi() +abstract class WebAuthnBridgeApi { + /// Creates a new passkey credential on the device. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions`. + /// Returns a JSON-serialized `RegistrationResponseJSON`. + @async + String createCredential(String optionsJson); + + /// Retrieves a passkey credential assertion for authentication. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions`. + /// Returns a JSON-serialized `AuthenticationResponseJSON`. + @async + String getCredential(String optionsJson); + + /// Returns whether the current device/platform supports passkeys. + @async + bool isPasskeySupported(); +} diff --git a/packages/auth/amplify_auth_cognito/pubspec.yaml b/packages/auth/amplify_auth_cognito/pubspec.yaml index 36baba6f229..e53269d0a48 100644 --- a/packages/auth/amplify_auth_cognito/pubspec.yaml +++ b/packages/auth/amplify_auth_cognito/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: amplify_flutter: ">=2.10.0 <2.11.0" amplify_secure_storage: ">=0.5.15 <0.6.0" async: ^2.10.0 + crypto: ^3.0.7 + ffi: ^2.0.2 flutter: sdk: flutter meta: ^1.16.0 diff --git a/packages/auth/amplify_auth_cognito/test/linux_webauthn_platform_test.dart b/packages/auth/amplify_auth_cognito/test/linux_webauthn_platform_test.dart new file mode 100644 index 00000000000..d15c8113c12 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/test/linux_webauthn_platform_test.dart @@ -0,0 +1,606 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: unnecessary_underscores + +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:amplify_auth_cognito/src/linux/libfido2_bindings.dart'; +import 'package:amplify_auth_cognito/src/linux/linux_webauthn_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Mock LibFido2Bindings for testing. +class MockLibFido2Bindings extends LibFido2Bindings { + MockLibFido2Bindings({ + this.mockDeviceCount = 1, + this.mockManifestResult = fidoOk, + this.mockOpenResult = fidoOk, + this.mockMakeCredResult = fidoOk, + this.mockGetAssertResult = fidoOk, + }) : super(DynamicLibrary.process()); + + final int mockDeviceCount; + final int mockManifestResult; + final int mockOpenResult; + final int mockMakeCredResult; + final int mockGetAssertResult; + + Pointer? _mockDevice; + Pointer? _mockCred; + Pointer? _mockAssert; + + @override + void Function(int flags) get fidoInit => (_) {}; + + @override + Pointer Function(int n) get fidoDevInfoNew => + (_) => calloc(1024); + + @override + void Function(Pointer devlist, int n) get fidoDevInfoFree => + (devlist, _) { + if (devlist.value != nullptr) { + calloc.free(devlist.value); + } + }; + + @override + int Function(Pointer devlist, int n, Pointer found) + get fidoDevInfoManifest => (_, __, found) { + found.value = mockDeviceCount; + return mockManifestResult; + }; + + @override + Pointer Function(Pointer di) get fidoDevInfoPath => + (_) => '/dev/hidraw0'.toNativeUtf8(); + + @override + Pointer Function(Pointer devlist, int idx) get fidoDevInfoPtr => + (devlist, _) => devlist; + + @override + Pointer Function() get fidoDevNew => () { + _mockDevice = calloc(256); + return _mockDevice!; + }; + + @override + void Function(Pointer dev) get fidoDevFree => (dev) { + if (_mockDevice != null && _mockDevice != nullptr) { + calloc.free(_mockDevice!); + _mockDevice = null; + } + }; + + @override + int Function(Pointer dev, Pointer path) get fidoDevOpen => + (_, __) => mockOpenResult; + + @override + int Function(Pointer dev) get fidoDevClose => + (_) => fidoOk; + + @override + Pointer Function() get fidoCredNew => () { + _mockCred = calloc(1024); + return _mockCred!; + }; + + @override + void Function(Pointer cred) get fidoCredFree => (cred) { + if (_mockCred != null && _mockCred != nullptr) { + calloc.free(_mockCred!); + _mockCred = null; + } + }; + + @override + int Function(Pointer cred, int type) get fidoCredSetType => + (_, __) => fidoOk; + + @override + int Function(Pointer cred, Pointer hash, int hashLen) + get fidoCredSetClientdataHash => + (_, __, ___) => fidoOk; + + @override + int Function(Pointer cred, Pointer rpId, Pointer rpName) + get fidoCredSetRp => + (_, __, ___) => fidoOk; + + @override + int Function( + Pointer cred, + Pointer userId, + int userIdLen, + Pointer userName, + Pointer displayName, + Pointer icon, + ) + get fidoCredSetUser => + (_, __, ___, ____, _____, ______) => fidoOk; + + @override + int Function(Pointer cred, int rk) get fidoCredSetRk => + (_, __) => fidoOk; + + @override + int Function(Pointer cred, int uv) get fidoCredSetUv => + (_, __) => fidoOk; + + @override + int Function(Pointer dev, Pointer cred, Pointer pin) + get fidoDevMakeCred => + (_, __, ___) => mockMakeCredResult; + + @override + Pointer Function(Pointer cred) get fidoCredIdPtr => (_) { + final bytes = Uint8List.fromList(utf8.encode('mock-cred-id')); + final ptr = calloc(bytes.length); + ptr.asTypedList(bytes.length).setAll(0, bytes); + return ptr; + }; + + @override + int Function(Pointer cred) get fidoCredIdLen => + (_) => utf8.encode('mock-cred-id').length; + + @override + Pointer Function(Pointer cred) get fidoCredAuthdataPtr => (_) { + // Return mock authenticator data (32-byte rpIdHash + flags + counter + AAGUID + credId + pubKey) + final mockAuthData = Uint8List(37); + final ptr = calloc(mockAuthData.length); + ptr.asTypedList(mockAuthData.length).setAll(0, mockAuthData); + return ptr; + }; + + @override + int Function(Pointer cred) get fidoCredAuthdataLen => + (_) => 37; + + @override + Pointer Function(Pointer cred) get fidoCredX5cPtr => + (_) => calloc(1); + + @override + int Function(Pointer cred) get fidoCredX5cLen => + (_) => 0; + + @override + Pointer Function(Pointer cred) get fidoCredSigPtr => + (_) => calloc(1); + + @override + int Function(Pointer cred) get fidoCredSigLen => + (_) => 0; + + @override + Pointer Function(Pointer cred) get fidoCredClientdataHashPtr => + (_) => calloc(32); + + @override + int Function(Pointer cred) get fidoCredClientdataHashLen => + (_) => 32; + + @override + Pointer Function(Pointer cred) get fidoCredFmt => + (_) => 'packed'.toNativeUtf8(); + + @override + Pointer Function() get fidoAssertNew => () { + _mockAssert = calloc(1024); + return _mockAssert!; + }; + + @override + void Function(Pointer assert_) get fidoAssertFree => (assert_) { + if (_mockAssert != null && _mockAssert != nullptr) { + calloc.free(_mockAssert!); + _mockAssert = null; + } + }; + + @override + int Function(Pointer assert_, Pointer hash, int len) + get fidoAssertSetClientdataHash => + (_, __, ___) => fidoOk; + + @override + int Function(Pointer assert_, Pointer rpId) get fidoAssertSetRp => + (_, __) => fidoOk; + + @override + int Function(Pointer assert_, int uv) get fidoAssertSetUv => + (_, __) => fidoOk; + + @override + int Function(Pointer assert_, Pointer credId, int len) + get fidoAssertAllowCred => + (_, __, ___) => fidoOk; + + @override + int Function(Pointer dev, Pointer assert_, Pointer pin) + get fidoDevGetAssert => + (_, __, ___) => mockGetAssertResult; + + @override + int Function(Pointer assert_) get fidoAssertCount => + (_) => 1; + + @override + Pointer Function(Pointer assert_, int idx) get fidoAssertAuthdataPtr => + (_, __) { + final mockAuthData = Uint8List(37); + final ptr = calloc(mockAuthData.length); + ptr.asTypedList(mockAuthData.length).setAll(0, mockAuthData); + return ptr; + }; + + @override + int Function(Pointer assert_, int idx) get fidoAssertAuthdataLen => + (_, __) => 37; + + @override + Pointer Function(Pointer assert_, int idx) get fidoAssertSigPtr => + (_, __) { + final sig = Uint8List(64); + final ptr = calloc(sig.length); + ptr.asTypedList(sig.length).setAll(0, sig); + return ptr; + }; + + @override + int Function(Pointer assert_, int idx) get fidoAssertSigLen => + (_, __) => 64; + + @override + Pointer Function(Pointer assert_, int idx) get fidoAssertUserIdPtr => + (_, __) { + final userId = utf8.encode('user123'); + final ptr = calloc(userId.length); + ptr.asTypedList(userId.length).setAll(0, userId); + return ptr; + }; + + @override + int Function(Pointer assert_, int idx) get fidoAssertUserIdLen => + (_, __) => utf8.encode('user123').length; + + @override + Pointer Function(Pointer assert_, int idx) get fidoAssertIdPtr => + (_, __) { + final credId = utf8.encode('mock-cred-id'); + final ptr = calloc(credId.length); + ptr.asTypedList(credId.length).setAll(0, credId); + return ptr; + }; + + @override + int Function(Pointer assert_, int idx) get fidoAssertIdLen => + (_, __) => utf8.encode('mock-cred-id').length; +} + +void main() { + group('LinuxWebAuthnPlatform', () { + group('isPasskeySupported', () { + test('returns false when libfido2 is not available', () async { + // Note: This test will return true if libfido2.so.1 or libfido2.so is + // installed on the test system. This is expected behavior - the platform + // SHOULD detect and use an available libfido2 installation. + // To test the "not available" path, you would need a system without + // libfido2 installed, or mock DynamicLibrary.open() to throw. + final platform = LinuxWebAuthnPlatform(); + // Result depends on whether libfido2 is installed on test system + await platform.isPasskeySupported(); + }); + + test('returns true when bindings is provided', () async { + final bindings = MockLibFido2Bindings(); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + expect(await platform.isPasskeySupported(), isTrue); + }); + }); + + group('_ensureSupported', () { + test( + 'throws PasskeyNotSupportedException when no devices found', + () async { + // Use mock bindings that report zero devices to test the unsupported path + final bindings = MockLibFido2Bindings(mockDeviceCount: 0); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + }); + + group('createCredential', () { + test('returns JSON response on success', () async { + final bindings = MockLibFido2Bindings(); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + final result = await platform.createCredential(optionsJson); + final decoded = jsonDecode(result) as Map; + + expect(decoded['type'], 'public-key'); + expect(decoded['id'], isNotEmpty); + expect(decoded['response'], isA>()); + }); + + test('throws PasskeyCancelledException for fidoErrNotAllowed', () async { + final bindings = MockLibFido2Bindings( + mockMakeCredResult: fidoErrNotAllowed, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }); + + test( + 'throws PasskeyCancelledException for fidoErrActionTimeout', + () async { + final bindings = MockLibFido2Bindings( + mockMakeCredResult: fidoErrActionTimeout, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyRegistrationFailedException for unknown error code in registration', + () async { + final bindings = MockLibFido2Bindings(mockMakeCredResult: fidoErrTx); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyNotSupportedException when no devices found', + () async { + final bindings = MockLibFido2Bindings(mockDeviceCount: 0); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyNotSupportedException when device open fails', + () async { + final bindings = MockLibFido2Bindings(mockOpenResult: fidoErrTx); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + }); + + group('getCredential', () { + test('returns JSON response on success', () async { + final bindings = MockLibFido2Bindings(); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [ + {"type": "public-key", "id": "Y3JlZC0x"} + ] +} +'''; + + final result = await platform.getCredential(optionsJson); + final decoded = jsonDecode(result) as Map; + + expect(decoded['type'], 'public-key'); + expect(decoded['id'], isNotEmpty); + expect(decoded['response'], isA>()); + expect( + (decoded['response'] as Map)['signature'], + isNotEmpty, + ); + }); + + test('throws PasskeyCancelledException for fidoErrNotAllowed', () async { + final bindings = MockLibFido2Bindings( + mockGetAssertResult: fidoErrNotAllowed, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }); + + test( + 'throws PasskeyCancelledException for fidoErrActionTimeout', + () async { + final bindings = MockLibFido2Bindings( + mockGetAssertResult: fidoErrActionTimeout, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException for fidoErrPinRequired', + () async { + final bindings = MockLibFido2Bindings( + mockGetAssertResult: fidoErrPinRequired, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException for fidoErrUvBlocked', + () async { + final bindings = MockLibFido2Bindings( + mockGetAssertResult: fidoErrUvBlocked, + ); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException for unknown error code', + () async { + final bindings = MockLibFido2Bindings(mockGetAssertResult: fidoErrTx); + final platform = LinuxWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito/test/pigeon_webauthn_credential_platform_test.dart b/packages/auth/amplify_auth_cognito/test/pigeon_webauthn_credential_platform_test.dart new file mode 100644 index 00000000000..1afa16a80fc --- /dev/null +++ b/packages/auth/amplify_auth_cognito/test/pigeon_webauthn_credential_platform_test.dart @@ -0,0 +1,234 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito/src/pigeon_webauthn_credential_platform.dart'; +import 'package:amplify_auth_cognito/src/webauthn_bridge.g.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late BinaryMessenger messenger; + late WebAuthnBridgeApi bridge; + late PigeonWebAuthnCredentialPlatform platform; + + const createChannel = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.createCredential'; + const getChannel = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.getCredential'; + const isSupportedChannel = + 'dev.flutter.pigeon.amplify_auth_cognito.WebAuthnBridgeApi.isPasskeySupported'; + + setUp(() { + messenger = TestDefaultBinaryMessenger( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger, + ); + bridge = WebAuthnBridgeApi(binaryMessenger: messenger); + platform = PigeonWebAuthnCredentialPlatform(bridge); + }); + + void setMockHandler( + String channel, { + Object? result, + String? errorCode, + String? errorMessage, + }) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler( + BasicMessageChannel( + channel, + WebAuthnBridgeApi.pigeonChannelCodec, + ), + (Object? message) async { + if (errorCode != null) { + return [errorCode, errorMessage, null]; + } + return [result]; + }, + ); + } + + tearDown(() { + for (final channel in [createChannel, getChannel, isSupportedChannel]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler( + BasicMessageChannel( + channel, + WebAuthnBridgeApi.pigeonChannelCodec, + ), + null, + ); + } + }); + + group('PigeonWebAuthnCredentialPlatform', () { + group('createCredential', () { + test('returns result on success', () async { + setMockHandler(createChannel, result: '{"id":"cred-1"}'); + final result = await platform.createCredential('{}'); + expect(result, '{"id":"cred-1"}'); + }); + + test('maps cancelled error to PasskeyCancelledException', () async { + setMockHandler( + createChannel, + errorCode: WebAuthnErrorCodes.cancelled, + errorMessage: 'User cancelled', + ); + expect( + () => platform.createCredential('{}'), + throwsA(isA()), + ); + }); + + test('maps notSupported error to PasskeyNotSupportedException', () async { + setMockHandler( + createChannel, + errorCode: WebAuthnErrorCodes.notSupported, + errorMessage: 'Not supported', + ); + expect( + () => platform.createCredential('{}'), + throwsA(isA()), + ); + }); + + test('maps rpMismatch error to PasskeyRpMismatchException', () async { + setMockHandler( + createChannel, + errorCode: WebAuthnErrorCodes.rpMismatch, + errorMessage: 'RP mismatch', + ); + expect( + () => platform.createCredential('{}'), + throwsA(isA()), + ); + }); + + test( + 'maps unknown error to PasskeyRegistrationFailedException', + () async { + setMockHandler( + createChannel, + errorCode: 'unknownError', + errorMessage: 'Something failed', + ); + expect( + () => platform.createCredential('{}'), + throwsA(isA()), + ); + }, + ); + + test('preserves error message', () async { + setMockHandler( + createChannel, + errorCode: WebAuthnErrorCodes.cancelled, + errorMessage: 'User tapped cancel', + ); + try { + await platform.createCredential('{}'); + fail('Expected PasskeyCancelledException'); + } on PasskeyCancelledException catch (e) { + expect(e.message, 'User tapped cancel'); + } + }); + + test('preserves underlying exception', () async { + setMockHandler( + createChannel, + errorCode: WebAuthnErrorCodes.cancelled, + errorMessage: 'Cancelled', + ); + try { + await platform.createCredential('{}'); + fail('Expected PasskeyCancelledException'); + } on PasskeyCancelledException catch (e) { + expect(e.underlyingException, isA()); + } + }); + }); + + group('getCredential', () { + test('returns result on success', () async { + setMockHandler(getChannel, result: '{"id":"cred-1"}'); + final result = await platform.getCredential('{}'); + expect(result, '{"id":"cred-1"}'); + }); + + test('maps cancelled error to PasskeyCancelledException', () async { + setMockHandler( + getChannel, + errorCode: WebAuthnErrorCodes.cancelled, + errorMessage: 'User cancelled', + ); + expect( + () => platform.getCredential('{}'), + throwsA(isA()), + ); + }); + + test('maps notSupported error to PasskeyNotSupportedException', () async { + setMockHandler( + getChannel, + errorCode: WebAuthnErrorCodes.notSupported, + errorMessage: 'Not supported', + ); + expect( + () => platform.getCredential('{}'), + throwsA(isA()), + ); + }); + + test('maps rpMismatch error to PasskeyRpMismatchException', () async { + setMockHandler( + getChannel, + errorCode: WebAuthnErrorCodes.rpMismatch, + errorMessage: 'RP mismatch', + ); + expect( + () => platform.getCredential('{}'), + throwsA(isA()), + ); + }); + + test('maps unknown error to PasskeyAssertionFailedException', () async { + setMockHandler( + getChannel, + errorCode: 'unknownError', + errorMessage: 'Something failed', + ); + expect( + () => platform.getCredential('{}'), + throwsA(isA()), + ); + }); + }); + + group('isPasskeySupported', () { + test('returns true when supported', () async { + setMockHandler(isSupportedChannel, result: true); + expect(await platform.isPasskeySupported(), isTrue); + }); + + test('returns false when not supported', () async { + setMockHandler(isSupportedChannel, result: false); + expect(await platform.isPasskeySupported(), isFalse); + }); + + test('maps PlatformException to PasskeyException', () async { + setMockHandler( + isSupportedChannel, + errorCode: 'unknown', + errorMessage: 'Check failed', + ); + expect( + () => platform.isPasskeySupported(), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito/test/windows_webauthn_platform_test.dart b/packages/auth/amplify_auth_cognito/test/windows_webauthn_platform_test.dart new file mode 100644 index 00000000000..090c6588bfa --- /dev/null +++ b/packages/auth/amplify_auth_cognito/test/windows_webauthn_platform_test.dart @@ -0,0 +1,500 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:amplify_auth_cognito/src/windows/webauthn_bindings.dart'; +import 'package:amplify_auth_cognito/src/windows/windows_webauthn_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Mock WebAuthnBindings for testing. +class MockWebAuthnBindings extends WebAuthnBindings { + MockWebAuthnBindings({ + this.mockApiVersion = 4, + this.mockIsAvailable = true, + this.mockMakeCredentialResult = S_OK, + this.mockGetAssertionResult = S_OK, + this.mockActiveWindow = 1, + }) : super( + webauthnLib: DynamicLibrary.process(), + user32Lib: DynamicLibrary.process(), + ); + + final int mockApiVersion; + final bool mockIsAvailable; + final int mockMakeCredentialResult; + final int mockGetAssertionResult; + final int mockActiveWindow; + + @override + int Function() get getApiVersionNumber => + () => mockApiVersion; + + @override + int Function(Pointer) + get isUserVerifyingPlatformAuthenticatorAvailable => (pbIsAvailable) { + pbIsAvailable.value = mockIsAvailable ? 1 : 0; + return S_OK; + }; + + @override + int Function() get getActiveWindow => + () => mockActiveWindow; + + @override + int Function( + int hWnd, + Pointer rpInfo, + Pointer userInfo, + Pointer pubKeyCredParams, + Pointer clientData, + Pointer options, + Pointer ppResult, + ) + get makeCredential => + ( + hWnd, + rpInfo, + userInfo, + pubKeyCredParams, + clientData, + options, + ppResult, + ) { + if (mockMakeCredentialResult == S_OK) { + // Allocate a mock result struct with JSON response. + // Structure layout: we write the JSON response at expected offsets. + final mockStruct = calloc(256); + const jsonResponse = '{"id":"mock-credential-id"}'; + final jsonBytes = utf8.encode(jsonResponse); + + // Write cbRegistrationResponseJSON at offset 152 (DWORD = Uint32) + (mockStruct + 152).cast().value = jsonBytes.length; + + // Allocate JSON bytes buffer and write pointer at offset 160 + final jsonBuffer = calloc(jsonBytes.length); + for (var i = 0; i < jsonBytes.length; i++) { + (jsonBuffer + i).value = jsonBytes[i]; + } + (mockStruct + 160).cast>().value = jsonBuffer; + + ppResult.value = mockStruct.cast(); + } + return mockMakeCredentialResult; + }; + + @override + int Function( + int hWnd, + Pointer rpId, + Pointer clientData, + Pointer options, + Pointer ppResult, + ) + get getAssertion => (hWnd, rpId, clientData, options, ppResult) { + if (mockGetAssertionResult == S_OK) { + // Allocate a mock result struct with JSON response. + final mockStruct = calloc(256); + const jsonResponse = '{"id":"mock-assertion-id"}'; + final jsonBytes = utf8.encode(jsonResponse); + + // Write cbAuthenticationResponseJSON at offset 104 (DWORD = Uint32) + (mockStruct + 104).cast().value = jsonBytes.length; + + // Allocate JSON bytes buffer and write pointer at offset 112 + final jsonBuffer = calloc(jsonBytes.length); + for (var i = 0; i < jsonBytes.length; i++) { + (jsonBuffer + i).value = jsonBytes[i]; + } + (mockStruct + 112).cast>().value = jsonBuffer; + + ppResult.value = mockStruct.cast(); + } + return mockGetAssertionResult; + }; + + @override + void Function(Pointer) get freeCredentialAttestation => (pAttestation) { + // Free the mock struct and JSON buffer we allocated. + if (pAttestation != nullptr) { + final pbJson = (pAttestation.cast() + 160) + .cast>() + .value; + if (pbJson != nullptr) { + calloc.free(pbJson); + } + calloc.free(pAttestation); + } + }; + + @override + void Function(Pointer) get freeAssertion => (pAssertion) { + // Free the mock struct and JSON buffer we allocated. + if (pAssertion != nullptr) { + final pbJson = (pAssertion.cast() + 112) + .cast>() + .value; + if (pbJson != nullptr) { + calloc.free(pbJson); + } + calloc.free(pAssertion); + } + }; +} + +void main() { + group('WindowsWebAuthnPlatform', () { + group('isPasskeySupported', () { + test('returns false when API version < 4', () async { + final bindings = MockWebAuthnBindings(mockApiVersion: 3); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + expect(await platform.isPasskeySupported(), isFalse); + }); + + test( + 'returns true when API version >= 4 and authenticator is available', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockIsAvailable: true, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + expect(await platform.isPasskeySupported(), isTrue); + }, + ); + + test( + 'returns false when API version >= 4 but authenticator is unavailable', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockIsAvailable: false, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + expect(await platform.isPasskeySupported(), isFalse); + }, + ); + + test('returns false on exception', () async { + // Create bindings that will throw when querying availability + final bindings = _ThrowingMockBindings(); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + expect(await platform.isPasskeySupported(), isFalse); + }); + }); + + group('createCredential', () { + test('returns JSON response on success', () async { + final bindings = MockWebAuthnBindings(mockApiVersion: 4); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] +} +'''; + + final result = await platform.createCredential(optionsJson); + expect(result, contains('mock-credential-id')); + }); + + test('throws PasskeyCancelledException for NTE_USER_CANCELLED', () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockMakeCredentialResult: NTE_USER_CANCELLED, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }); + + test( + 'throws PasskeyRegistrationFailedException for NTE_INVALID_PARAMETER', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockMakeCredentialResult: NTE_INVALID_PARAMETER, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyRegistrationFailedException for unknown HRESULT', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockMakeCredentialResult: 0x80004005, // E_FAIL + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyNotSupportedException when API version < 4', + () async { + final bindings = MockWebAuthnBindings(mockApiVersion: 3); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyRegistrationFailedException when no active window', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockActiveWindow: 0, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rp": {"id": "example.com", "name": "Example"}, + "user": {"id": "user123", "name": "testuser", "displayName": "Test User"}, + "challenge": "Y2hhbGxlbmdl", + "pubKeyCredParams": [] +} +'''; + + expect( + () => platform.createCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + }); + + group('getCredential', () { + test('returns JSON response on success', () async { + final bindings = MockWebAuthnBindings(mockApiVersion: 4); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + final result = await platform.getCredential(optionsJson); + expect(result, contains('mock-assertion-id')); + }); + + test('throws PasskeyCancelledException for NTE_USER_CANCELLED', () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockGetAssertionResult: NTE_USER_CANCELLED, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }); + + test( + 'throws PasskeyAssertionFailedException for NTE_NOT_FOUND', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockGetAssertionResult: NTE_NOT_FOUND, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException for NTE_INVALID_PARAMETER', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockGetAssertionResult: NTE_INVALID_PARAMETER, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException for unknown HRESULT', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockGetAssertionResult: 0x80004005, // E_FAIL + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyNotSupportedException when API version < 4', + () async { + final bindings = MockWebAuthnBindings(mockApiVersion: 3); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + + test( + 'throws PasskeyAssertionFailedException when no active window', + () async { + final bindings = MockWebAuthnBindings( + mockApiVersion: 4, + mockActiveWindow: 0, + ); + final platform = WindowsWebAuthnPlatform(bindings: bindings); + + const optionsJson = ''' +{ + "rpId": "example.com", + "challenge": "Y2hhbGxlbmdl", + "allowCredentials": [] +} +'''; + + expect( + () => platform.getCredential(optionsJson), + throwsA(isA()), + ); + }, + ); + }); + }); +} + +/// Mock bindings that throw an exception when queried. +class _ThrowingMockBindings extends WebAuthnBindings { + _ThrowingMockBindings() + : super( + webauthnLib: DynamicLibrary.process(), + user32Lib: DynamicLibrary.process(), + ); + + @override + int Function() get getApiVersionNumber => + () => throw Exception('Test error'); +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/amplify_auth_cognito_dart.dart b/packages/auth/amplify_auth_cognito_dart/lib/amplify_auth_cognito_dart.dart index 41d2fea6b49..b662da0c723 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/amplify_auth_cognito_dart.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/amplify_auth_cognito_dart.dart @@ -21,6 +21,7 @@ export 'src/model/attribute/cognito_update_user_attribute_plugin_options.dart'; export 'src/model/attribute/cognito_update_user_attributes_plugin_options.dart'; export 'src/model/auth_result.dart'; export 'src/model/auto_sign_in/cognito_auto_sign_in_plugin_options.dart'; +export 'src/model/cognito_webauthn_credential.dart'; export 'src/model/device/cognito_device.dart'; export 'src/model/mfa/cognito_verify_totp_setup_plugin_options.dart'; export 'src/model/password/cognito_confirm_reset_password_plugin_options.dart'; @@ -49,4 +50,7 @@ export 'src/model/signup/cognito_resend_sign_up_code_plugin_options.dart'; export 'src/model/signup/cognito_resend_sign_up_code_result.dart'; export 'src/model/signup/cognito_sign_up_plugin_options.dart'; export 'src/model/signup/cognito_sign_up_result.dart'; +export 'src/model/webauthn/passkey_types.dart'; +export 'src/model/webauthn/webauthn_credential_platform.dart'; +export 'src/sdk/cognito_webauthn_client.dart'; export 'src/sdk/sdk_exception.dart' hide transformSdkException; diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart index 411606af68f..e564c9fc7e3 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/auth_plugin_impl.dart @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import 'dart:async'; +import 'dart:convert'; // ignore: implementation_imports import 'package:amplify_analytics_pinpoint_dart/src/impl/analytics_client/endpoint_client/endpoint_info_store_manager.dart'; @@ -125,6 +126,16 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface return cognitoIdp; } + /// The Cognito WebAuthn client for raw API operations. + CognitoWebAuthnClient get _cognitoWebAuthn { + final authOutputs = _authOutputs; + final region = authOutputs.userPoolId!.split('_').first; + return CognitoWebAuthnClient( + region: region, + httpClient: stateMachine.getOrCreate(), + ); + } + AuthOutputs get _authOutputs { final authOutputs = _stateMachine.get(); if (authOutputs?.userPoolId == null || @@ -208,6 +219,13 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface APIAuthorizationType.userPools.authProviderToken, CognitoUserPoolsAuthProvider(), ); + + // Register the WebAuthn credential platform for web. + if (zIsWeb) { + _stateMachine.addInstance( + WebAuthnCredentialPlatform(), + ); + } } @override @@ -1141,6 +1159,81 @@ class AmplifyAuthCognitoDart extends AuthPluginInterface return signOutResult; } + @override + Future associateWebAuthnCredential() async { + // 1. Get access token (throws SignedOutException if not signed in) + final tokens = await stateMachine.getUserPoolTokens(); + final accessToken = tokens.accessToken.raw; + + // 2. Start registration with Cognito + final createOptions = await _cognitoWebAuthn.startWebAuthnRegistration( + accessToken: accessToken, + ); + + // 3. Get platform bridge and run ceremony + final platform = stateMachine.get(); + if (platform == null) { + throw const PasskeyNotSupportedException( + 'WebAuthn platform bridge not available', + ); + } + final credentialJson = await platform.createCredential( + jsonEncode(createOptions.toJson()), + ); + + // 4. Complete registration with Cognito + final credential = PasskeyCreateResult.fromJson( + jsonDecode(credentialJson) as Map, + ); + await _cognitoWebAuthn.completeWebAuthnRegistration( + accessToken: accessToken, + credential: credential, + ); + } + + @override + Future> listWebAuthnCredentials() async { + final allCredentials = []; + + String? nextToken; + do { + final tokens = await stateMachine.getUserPoolTokens(); + const pageLimit = 20; + final result = await _cognitoWebAuthn.listWebAuthnCredentials( + accessToken: tokens.accessToken.raw, + maxResults: pageLimit, + nextToken: nextToken, + ); + + allCredentials.addAll([ + for (final description in result.credentials) + CognitoWebAuthnCredential.fromDescription(description), + ]); + + nextToken = result.nextToken; + } while (nextToken != null); + + return allCredentials; + } + + @override + Future deleteWebAuthnCredential(String credentialId) async { + final tokens = await stateMachine.getUserPoolTokens(); + await _cognitoWebAuthn.deleteWebAuthnCredential( + accessToken: tokens.accessToken.raw, + credentialId: credentialId, + ); + } + + @override + Future isPasskeySupported() async { + final platform = stateMachine.get(); + if (platform == null) { + return false; + } + return platform.isPasskeySupported(); + } + @override Future deleteUser() async { final tokens = await stateMachine.getUserPoolTokens(); diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/flows/constants.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/flows/constants.dart index 0c98f3e8551..ed59a9bcd25 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/flows/constants.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/flows/constants.dart @@ -110,4 +110,17 @@ abstract class CognitoConstants { /// The `PREFERRED_CHALLENGE` parameter static const preferredChallenge = 'PREFERRED_CHALLENGE'; + + /// The `CREDENTIAL_REQUEST_OPTIONS` challenge parameter. + /// + /// Contains JSON-serialized `PublicKeyCredentialRequestOptions` for WebAuthn + /// assertion ceremonies. + static const challengeParamCredentialRequestOptions = + 'CREDENTIAL_REQUEST_OPTIONS'; + + /// The `CREDENTIAL` challenge response parameter. + /// + /// Contains JSON-serialized `AuthenticationResponseJSON` from the WebAuthn + /// assertion ceremony. + static const challengeParamCredential = 'CREDENTIAL'; } diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/cognito_webauthn_credential.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/cognito_webauthn_credential.dart new file mode 100644 index 00000000000..2e6058c7b6d --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/cognito_webauthn_credential.dart @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/src/sdk/cognito_webauthn_client.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// {@template amplify_auth_cognito.cognito_webauthn_credential} +/// A WebAuthn/passkey credential registered with AWS Cognito. +/// {@endtemplate} +@immutable +class CognitoWebAuthnCredential extends AuthWebAuthnCredential + with AWSEquatable { + /// {@macro amplify_auth_cognito.cognito_webauthn_credential} + const CognitoWebAuthnCredential({ + required this.credentialId, + required this.relyingPartyId, + required this.createdAt, + this.friendlyName, + this.authenticatorAttachment, + this.authenticatorTransports, + }); + + /// Creates a [CognitoWebAuthnCredential] from a [WebAuthnCredentialDescription]. + factory CognitoWebAuthnCredential.fromDescription( + WebAuthnCredentialDescription description, + ) { + return CognitoWebAuthnCredential( + credentialId: description.credentialId, + relyingPartyId: description.relyingPartyId, + createdAt: description.createdAt, + friendlyName: description.friendlyCredentialName, + authenticatorAttachment: description.authenticatorAttachment, + authenticatorTransports: description.authenticatorTransports, + ); + } + + @override + final String credentialId; + + @override + final String relyingPartyId; + + @override + final DateTime createdAt; + + @override + final String? friendlyName; + + @override + final String? authenticatorAttachment; + + @override + final List? authenticatorTransports; + + @override + Map toJson() => { + 'credentialId': credentialId, + 'relyingPartyId': relyingPartyId, + 'createdAt': createdAt.toIso8601String(), + if (friendlyName != null) 'friendlyName': friendlyName, + if (authenticatorAttachment != null) + 'authenticatorAttachment': authenticatorAttachment, + if (authenticatorTransports != null) + 'authenticatorTransports': authenticatorTransports, + }; + + @override + List get props => [ + credentialId, + relyingPartyId, + createdAt, + friendlyName, + authenticatorAttachment, + authenticatorTransports, + ]; + + @override + String toString() { + return 'CognitoWebAuthnCredential{credentialId=$credentialId, relyingPartyId=$relyingPartyId, createdAt=$createdAt, friendlyName=$friendlyName}'; + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/signin/cognito_sign_in_plugin_options.g.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/signin/cognito_sign_in_plugin_options.g.dart index a91da6e0002..da958318da3 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/model/signin/cognito_sign_in_plugin_options.g.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/signin/cognito_sign_in_plugin_options.g.dart @@ -52,4 +52,5 @@ const _$AuthFactorTypeEnumMap = { AuthFactorType.passwordSrp: 'PASSWORD_SRP', AuthFactorType.emailOtp: 'EMAIL_OTP', AuthFactorType.smsOtp: 'SMS_OTP', + AuthFactorType.webAuthn: 'WEB_AUTHN', }; diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/passkey_types.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/passkey_types.dart new file mode 100644 index 00000000000..be1f004e527 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/passkey_types.dart @@ -0,0 +1,609 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// WebAuthn/passkey JSON serialization types for Cognito API exchange. +/// +/// These types model the W3C WebAuthn Level 3 JSON dictionaries used +/// for communication between the Cognito service and platform WebAuthn +/// APIs. All binary fields (challenge, credential IDs, authenticator +/// data, etc.) are represented as base64url-encoded strings. +library; + +/// {@template amplify_auth_cognito_dart.passkey_credential_descriptor} +/// Describes a public key credential for use in `excludeCredentials` +/// (registration) or `allowCredentials` (authentication). +/// {@endtemplate} +class PasskeyCredentialDescriptor { + /// {@macro amplify_auth_cognito_dart.passkey_credential_descriptor} + const PasskeyCredentialDescriptor({ + required this.id, + required this.type, + this.transports, + }); + + /// Creates a [PasskeyCredentialDescriptor] from a JSON map. + factory PasskeyCredentialDescriptor.fromJson(Map json) { + return PasskeyCredentialDescriptor( + id: json['id'] as String, + type: json['type'] as String, + transports: (json['transports'] as List?) + ?.map((e) => e as String) + .toList(), + ); + } + + /// The base64url-encoded credential ID. + final String id; + + /// The credential type, always `'public-key'`. + final String type; + + /// Optional transport hints (e.g. `'usb'`, `'nfc'`, `'ble'`, `'internal'`). + final List? transports; + + /// Serializes this descriptor to a JSON map. + Map toJson() { + final json = {'id': id, 'type': type}; + if (transports != null) { + json['transports'] = transports; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_rp_entity} +/// The relying party entity for a WebAuthn ceremony. +/// {@endtemplate} +class PasskeyRpEntity { + /// {@macro amplify_auth_cognito_dart.passkey_rp_entity} + const PasskeyRpEntity({required this.id, required this.name}); + + /// Creates a [PasskeyRpEntity] from a JSON map. + factory PasskeyRpEntity.fromJson(Map json) { + return PasskeyRpEntity( + id: json['id'] as String, + name: json['name'] as String, + ); + } + + /// The relying party identifier (typically the domain). + final String id; + + /// The human-readable relying party name. + final String name; + + /// Serializes this entity to a JSON map. + Map toJson() => {'id': id, 'name': name}; +} + +/// {@template amplify_auth_cognito_dart.passkey_user_entity} +/// The user entity for a WebAuthn registration ceremony. +/// {@endtemplate} +class PasskeyUserEntity { + /// {@macro amplify_auth_cognito_dart.passkey_user_entity} + const PasskeyUserEntity({ + required this.id, + required this.name, + required this.displayName, + }); + + /// Creates a [PasskeyUserEntity] from a JSON map. + factory PasskeyUserEntity.fromJson(Map json) { + return PasskeyUserEntity( + id: json['id'] as String, + name: json['name'] as String, + displayName: json['displayName'] as String, + ); + } + + /// The base64url-encoded user ID. + final String id; + + /// The user account name (e.g. email or username). + final String name; + + /// The human-readable display name. + final String displayName; + + /// Serializes this entity to a JSON map. + Map toJson() => { + 'id': id, + 'name': name, + 'displayName': displayName, + }; +} + +/// {@template amplify_auth_cognito_dart.passkey_authenticator_selection} +/// Authenticator selection criteria for a WebAuthn registration ceremony. +/// {@endtemplate} +class PasskeyAuthenticatorSelection { + /// {@macro amplify_auth_cognito_dart.passkey_authenticator_selection} + const PasskeyAuthenticatorSelection({ + this.requireResidentKey, + this.residentKey, + this.userVerification, + this.authenticatorAttachment, + }); + + /// Creates a [PasskeyAuthenticatorSelection] from a JSON map. + factory PasskeyAuthenticatorSelection.fromJson(Map json) { + return PasskeyAuthenticatorSelection( + requireResidentKey: json['requireResidentKey'] as bool?, + residentKey: json['residentKey'] as String?, + userVerification: json['userVerification'] as String?, + authenticatorAttachment: json['authenticatorAttachment'] as String?, + ); + } + + /// Whether the authenticator must create a client-side discoverable + /// credential (resident key). + final bool? requireResidentKey; + + /// The resident key requirement (`'discouraged'`, `'preferred'`, + /// `'required'`). + final String? residentKey; + + /// The user verification requirement (`'discouraged'`, `'preferred'`, + /// `'required'`). + final String? userVerification; + + /// The authenticator attachment modality (`'platform'`, + /// `'cross-platform'`). + final String? authenticatorAttachment; + + /// Serializes this selection criteria to a JSON map. + Map toJson() { + final json = {}; + if (requireResidentKey != null) { + json['requireResidentKey'] = requireResidentKey; + } + if (residentKey != null) { + json['residentKey'] = residentKey; + } + if (userVerification != null) { + json['userVerification'] = userVerification; + } + if (authenticatorAttachment != null) { + json['authenticatorAttachment'] = authenticatorAttachment; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_pub_key_cred_param} +/// A public key credential parameter specifying the type and algorithm. +/// {@endtemplate} +class PasskeyPubKeyCredParam { + /// {@macro amplify_auth_cognito_dart.passkey_pub_key_cred_param} + const PasskeyPubKeyCredParam({required this.type, required this.alg}); + + /// Creates a [PasskeyPubKeyCredParam] from a JSON map. + factory PasskeyPubKeyCredParam.fromJson(Map json) { + return PasskeyPubKeyCredParam( + type: json['type'] as String, + alg: json['alg'] as int, + ); + } + + /// The credential type, always `'public-key'`. + final String type; + + /// The COSE algorithm identifier (e.g. `-7` for ES256, `-257` for RS256). + final int alg; + + /// Serializes this parameter to a JSON map. + Map toJson() => {'type': type, 'alg': alg}; +} + +/// {@template amplify_auth_cognito_dart.passkey_create_options} +/// Options for creating a new passkey credential, derived from Cognito's +/// `StartWebAuthnRegistration` response. +/// +/// Corresponds to the W3C `PublicKeyCredentialCreationOptions` dictionary. +/// {@endtemplate} +class PasskeyCreateOptions { + /// {@macro amplify_auth_cognito_dart.passkey_create_options} + const PasskeyCreateOptions({ + required this.challenge, + required this.rp, + required this.user, + required this.pubKeyCredParams, + this.timeout, + this.excludeCredentials, + this.authenticatorSelection, + this.attestation, + }); + + /// Creates a [PasskeyCreateOptions] from a JSON map. + factory PasskeyCreateOptions.fromJson(Map json) { + return PasskeyCreateOptions( + challenge: json['challenge'] as String, + rp: PasskeyRpEntity.fromJson(json['rp'] as Map), + user: PasskeyUserEntity.fromJson(json['user'] as Map), + pubKeyCredParams: (json['pubKeyCredParams'] as List) + .map( + (e) => PasskeyPubKeyCredParam.fromJson(e as Map), + ) + .toList(), + timeout: json['timeout'] as int?, + excludeCredentials: (json['excludeCredentials'] as List?) + ?.map( + (e) => + PasskeyCredentialDescriptor.fromJson(e as Map), + ) + .toList(), + authenticatorSelection: json['authenticatorSelection'] == null + ? null + : PasskeyAuthenticatorSelection.fromJson( + json['authenticatorSelection'] as Map, + ), + attestation: json['attestation'] as String?, + ); + } + + /// The base64url-encoded challenge from Cognito. + final String challenge; + + /// The relying party entity. + final PasskeyRpEntity rp; + + /// The user entity. + final PasskeyUserEntity user; + + /// The acceptable public key credential parameters, ordered by preference. + final List pubKeyCredParams; + + /// The ceremony timeout in milliseconds. + final int? timeout; + + /// Credentials to exclude (already registered). + final List? excludeCredentials; + + /// Authenticator selection criteria. + final PasskeyAuthenticatorSelection? authenticatorSelection; + + /// The attestation conveyance preference (`'none'`, `'indirect'`, + /// `'direct'`, `'enterprise'`). + final String? attestation; + + /// Serializes these options to a JSON map. + Map toJson() { + final json = { + 'challenge': challenge, + 'rp': rp.toJson(), + 'user': user.toJson(), + 'pubKeyCredParams': pubKeyCredParams.map((e) => e.toJson()).toList(), + }; + if (timeout != null) { + json['timeout'] = timeout; + } + if (excludeCredentials != null) { + json['excludeCredentials'] = excludeCredentials! + .map((e) => e.toJson()) + .toList(); + } + if (authenticatorSelection != null) { + json['authenticatorSelection'] = authenticatorSelection!.toJson(); + } + if (attestation != null) { + json['attestation'] = attestation; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_attestation_response} +/// The authenticator response from a WebAuthn registration (create) +/// ceremony. +/// {@endtemplate} +class PasskeyAttestationResponse { + /// {@macro amplify_auth_cognito_dart.passkey_attestation_response} + const PasskeyAttestationResponse({ + required this.clientDataJSON, + required this.attestationObject, + this.authenticatorData, + this.publicKey, + this.publicKeyAlgorithm, + this.transports, + }); + + /// Creates a [PasskeyAttestationResponse] from a JSON map. + factory PasskeyAttestationResponse.fromJson(Map json) { + return PasskeyAttestationResponse( + clientDataJSON: json['clientDataJSON'] as String, + attestationObject: json['attestationObject'] as String, + authenticatorData: json['authenticatorData'] as String?, + publicKey: json['publicKey'] as String?, + publicKeyAlgorithm: json['publicKeyAlgorithm'] as int?, + transports: (json['transports'] as List?) + ?.map((e) => e as String) + .toList(), + ); + } + + /// The base64url-encoded client data JSON. + final String clientDataJSON; + + /// The base64url-encoded attestation object. + final String attestationObject; + + /// The base64url-encoded authenticator data (optional). + final String? authenticatorData; + + /// The base64url-encoded public key (optional, SPKI format). + final String? publicKey; + + /// The COSE algorithm identifier for the public key. + final int? publicKeyAlgorithm; + + /// Transport hints for the created credential. + final List? transports; + + /// Serializes this response to a JSON map. + Map toJson() { + final json = { + 'clientDataJSON': clientDataJSON, + 'attestationObject': attestationObject, + }; + if (authenticatorData != null) { + json['authenticatorData'] = authenticatorData; + } + if (publicKey != null) { + json['publicKey'] = publicKey; + } + if (publicKeyAlgorithm != null) { + json['publicKeyAlgorithm'] = publicKeyAlgorithm; + } + if (transports != null) { + json['transports'] = transports; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_create_result} +/// The result of a WebAuthn registration ceremony, corresponding to +/// the W3C `RegistrationResponseJSON` dictionary. +/// +/// This is sent to Cognito's `CompleteWebAuthnRegistration` API. +/// {@endtemplate} +class PasskeyCreateResult { + /// {@macro amplify_auth_cognito_dart.passkey_create_result} + const PasskeyCreateResult({ + required this.id, + required this.rawId, + required this.type, + required this.response, + required this.clientExtensionResults, + this.authenticatorAttachment, + }); + + /// Creates a [PasskeyCreateResult] from a JSON map. + factory PasskeyCreateResult.fromJson(Map json) { + return PasskeyCreateResult( + id: json['id'] as String, + rawId: json['rawId'] as String, + type: json['type'] as String, + response: PasskeyAttestationResponse.fromJson( + json['response'] as Map, + ), + clientExtensionResults: + json['clientExtensionResults'] as Map, + authenticatorAttachment: json['authenticatorAttachment'] as String?, + ); + } + + /// The base64url-encoded credential ID. + final String id; + + /// The base64url-encoded raw credential ID. + final String rawId; + + /// The credential type, always `'public-key'`. + final String type; + + /// The attestation response from the authenticator. + final PasskeyAttestationResponse response; + + /// Client extension results (may be empty). + final Map clientExtensionResults; + + /// The authenticator attachment modality used. + final String? authenticatorAttachment; + + /// Serializes this result to a JSON map. + Map toJson() { + final json = { + 'id': id, + 'rawId': rawId, + 'type': type, + 'response': response.toJson(), + 'clientExtensionResults': clientExtensionResults, + }; + if (authenticatorAttachment != null) { + json['authenticatorAttachment'] = authenticatorAttachment; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_get_options} +/// Options for retrieving a passkey credential assertion, derived from +/// Cognito's `CREDENTIAL_REQUEST_OPTIONS` challenge parameter. +/// +/// Corresponds to the W3C `PublicKeyCredentialRequestOptions` dictionary. +/// {@endtemplate} +class PasskeyGetOptions { + /// {@macro amplify_auth_cognito_dart.passkey_get_options} + const PasskeyGetOptions({ + required this.challenge, + required this.rpId, + this.timeout, + this.allowCredentials, + this.userVerification, + }); + + /// Creates a [PasskeyGetOptions] from a JSON map. + factory PasskeyGetOptions.fromJson(Map json) { + return PasskeyGetOptions( + challenge: json['challenge'] as String, + rpId: json['rpId'] as String, + timeout: json['timeout'] as int?, + allowCredentials: (json['allowCredentials'] as List?) + ?.map( + (e) => + PasskeyCredentialDescriptor.fromJson(e as Map), + ) + .toList(), + userVerification: json['userVerification'] as String?, + ); + } + + /// The base64url-encoded challenge from Cognito. + final String challenge; + + /// The relying party identifier. + final String rpId; + + /// The ceremony timeout in milliseconds. + final int? timeout; + + /// Credentials that are allowed (registered for this user). + final List? allowCredentials; + + /// The user verification requirement. + final String? userVerification; + + /// Serializes these options to a JSON map. + Map toJson() { + final json = {'challenge': challenge, 'rpId': rpId}; + if (timeout != null) { + json['timeout'] = timeout; + } + if (allowCredentials != null) { + json['allowCredentials'] = allowCredentials! + .map((e) => e.toJson()) + .toList(); + } + if (userVerification != null) { + json['userVerification'] = userVerification; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_assertion_response} +/// The authenticator response from a WebAuthn authentication (get) +/// ceremony. +/// {@endtemplate} +class PasskeyAssertionResponse { + /// {@macro amplify_auth_cognito_dart.passkey_assertion_response} + const PasskeyAssertionResponse({ + required this.clientDataJSON, + required this.authenticatorData, + required this.signature, + this.userHandle, + }); + + /// Creates a [PasskeyAssertionResponse] from a JSON map. + factory PasskeyAssertionResponse.fromJson(Map json) { + return PasskeyAssertionResponse( + clientDataJSON: json['clientDataJSON'] as String, + authenticatorData: json['authenticatorData'] as String, + signature: json['signature'] as String, + userHandle: json['userHandle'] as String?, + ); + } + + /// The base64url-encoded client data JSON. + final String clientDataJSON; + + /// The base64url-encoded authenticator data. + final String authenticatorData; + + /// The base64url-encoded assertion signature. + final String signature; + + /// The base64url-encoded user handle (optional). + final String? userHandle; + + /// Serializes this response to a JSON map. + Map toJson() { + final json = { + 'clientDataJSON': clientDataJSON, + 'authenticatorData': authenticatorData, + 'signature': signature, + }; + if (userHandle != null) { + json['userHandle'] = userHandle; + } + return json; + } +} + +/// {@template amplify_auth_cognito_dart.passkey_get_result} +/// The result of a WebAuthn authentication ceremony, corresponding to +/// the W3C `AuthenticationResponseJSON` dictionary. +/// +/// This is sent as the `CREDENTIAL` value in Cognito's +/// `RespondToAuthChallenge` API. +/// {@endtemplate} +class PasskeyGetResult { + /// {@macro amplify_auth_cognito_dart.passkey_get_result} + const PasskeyGetResult({ + required this.id, + required this.rawId, + required this.type, + required this.response, + required this.clientExtensionResults, + this.authenticatorAttachment, + }); + + /// Creates a [PasskeyGetResult] from a JSON map. + factory PasskeyGetResult.fromJson(Map json) { + return PasskeyGetResult( + id: json['id'] as String, + rawId: json['rawId'] as String, + type: json['type'] as String, + response: PasskeyAssertionResponse.fromJson( + json['response'] as Map, + ), + clientExtensionResults: + json['clientExtensionResults'] as Map, + authenticatorAttachment: json['authenticatorAttachment'] as String?, + ); + } + + /// The base64url-encoded credential ID. + final String id; + + /// The base64url-encoded raw credential ID. + final String rawId; + + /// The credential type, always `'public-key'`. + final String type; + + /// The assertion response from the authenticator. + final PasskeyAssertionResponse response; + + /// Client extension results (may be empty). + final Map clientExtensionResults; + + /// The authenticator attachment modality used. + final String? authenticatorAttachment; + + /// Serializes this result to a JSON map. + Map toJson() { + final json = { + 'id': id, + 'rawId': rawId, + 'type': type, + 'response': response.toJson(), + 'clientExtensionResults': clientExtensionResults, + }; + if (authenticatorAttachment != null) { + json['authenticatorAttachment'] = authenticatorAttachment; + } + return json; + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform.dart new file mode 100644 index 00000000000..5d1dc7bdb08 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform.dart @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: comment_references + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform_stub.dart' + if (dart.library.js_interop) 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform_html.dart'; + +/// {@template amplify_auth_cognito_dart.webauthn_credential_platform} +/// Abstract interface for platform-specific WebAuthn/passkey operations. +/// +/// Platform implementations (iOS, Android, Web, macOS, Windows, Linux) +/// must implement this interface to provide WebAuthn ceremony support. +/// +/// The interface operates on JSON strings to keep serialization logic +/// in the shared Dart layer. The JSON formats follow the W3C WebAuthn +/// Level 3 specification dictionaries. +/// {@endtemplate} +abstract interface class WebAuthnCredentialPlatform { + /// Creates the platform-appropriate implementation. + /// + /// On web, returns the JS interop implementation. + /// On other platforms, returns the stub (native platforms use Pigeon bridge + /// instead). + factory WebAuthnCredentialPlatform() = WebAuthnCredentialPlatformImpl; + + /// Creates a new passkey credential on the device. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialCreationOptions` + /// object from Cognito's `StartWebAuthnRegistration` response. + /// + /// Returns a JSON-serialized `RegistrationResponseJSON` (W3C WebAuthn Level 3) + /// containing the newly created credential. + /// + /// Throws [PasskeyNotSupportedException] if passkeys are not supported. + /// Throws [PasskeyCancelledException] if the user cancels the ceremony. + /// Throws [PasskeyRegistrationFailedException] if credential creation fails. + Future createCredential(String optionsJson); + + /// Retrieves a passkey credential assertion for authentication. + /// + /// [optionsJson] is a JSON-serialized `PublicKeyCredentialRequestOptions` + /// object from Cognito's `CREDENTIAL_REQUEST_OPTIONS` challenge parameter. + /// + /// Returns a JSON-serialized `AuthenticationResponseJSON` (W3C WebAuthn Level 3) + /// containing the assertion result. + /// + /// Throws [PasskeyNotSupportedException] if passkeys are not supported. + /// Throws [PasskeyCancelledException] if the user cancels the ceremony. + /// Throws [PasskeyAssertionFailedException] if credential retrieval fails. + Future getCredential(String optionsJson); + + /// Returns whether the current device/platform supports passkeys. + /// + /// This is a lightweight check that does not trigger any UI prompts. + /// It checks for platform API availability (e.g., iOS 17.4+, Android API 28+, + /// browser WebAuthn support). + Future isPasskeySupported(); +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_html.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_html.dart new file mode 100644 index 00000000000..21613de5426 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_html.dart @@ -0,0 +1,250 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; +import 'package:amplify_auth_cognito_dart/src/util/base64url_encode.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:web/web.dart' as web; + +/// Web implementation of [WebAuthnCredentialPlatform] using the +/// Web Authentication API via `package:web` JS interop. +/// +/// Uses `navigator.credentials.create()` and `navigator.credentials.get()` +/// to perform WebAuthn registration and assertion ceremonies in the browser. +class WebAuthnCredentialPlatformImpl implements WebAuthnCredentialPlatform { + /// Creates a web [WebAuthnCredentialPlatform]. + const WebAuthnCredentialPlatformImpl(); + + @override + Future isPasskeySupported() async { + try { + // Check that the CredentialsContainer API exists on navigator. + // ignore: unnecessary_null_comparison, dead_code + if (web.window.navigator.credentials == null) return false; + + // Check that PublicKeyCredential is available in the global scope. + return globalContext.getProperty('PublicKeyCredential'.toJS) != + null; + } on Object { + return false; + } + } + + @override + Future createCredential(String optionsJson) async { + try { + final options = json.decode(optionsJson) as Map; + final publicKeyOptions = options.containsKey('publicKey') + ? options['publicKey'] as Map + : options; + + // Convert base64url fields to ArrayBuffer. + _convertField(publicKeyOptions, 'challenge'); + _convertUserIdField(publicKeyOptions); + _convertCredentialIds(publicKeyOptions, 'excludeCredentials'); + + final jsOptions = + {'publicKey': publicKeyOptions}.jsify() as JSObject; + + final credential = await web.window.navigator.credentials + .create(jsOptions as web.CredentialCreationOptions) + .toDart; + + if (credential == null) { + throw const PasskeyRegistrationFailedException( + 'Credential creation returned null', + ); + } + + final pkCredential = credential as web.PublicKeyCredential; + final response = + pkCredential.response as web.AuthenticatorAttestationResponse; + + final attestationResponse = { + 'clientDataJSON': _arrayBufferToBase64url(response.clientDataJSON), + 'attestationObject': _arrayBufferToBase64url( + response.attestationObject, + ), + 'transports': response + .getTransports() + .toDart + .map((t) => t.toDart) + .toList(), + 'authenticatorData': _arrayBufferToBase64url( + response.getAuthenticatorData(), + ), + 'publicKeyAlgorithm': response.getPublicKeyAlgorithm(), + }; + + final publicKey = response.getPublicKey(); + if (publicKey != null) { + attestationResponse['publicKey'] = _arrayBufferToBase64url(publicKey); + } + + final result = { + 'id': pkCredential.id, + 'rawId': _arrayBufferToBase64url(pkCredential.rawId), + 'type': pkCredential.type, + 'response': attestationResponse, + 'authenticatorAttachment': pkCredential.authenticatorAttachment, + 'clientExtensionResults': {}, + }; + + return json.encode(result); + } on PasskeyException { + rethrow; + } on Object catch (error) { + throw _mapDomException(error, isCreate: true); + } + } + + @override + Future getCredential(String optionsJson) async { + try { + final options = json.decode(optionsJson) as Map; + final publicKeyOptions = options.containsKey('publicKey') + ? options['publicKey'] as Map + : options; + + // Convert base64url fields to ArrayBuffer. + _convertField(publicKeyOptions, 'challenge'); + _convertCredentialIds(publicKeyOptions, 'allowCredentials'); + + final jsOptions = + {'publicKey': publicKeyOptions}.jsify() as JSObject; + + final credential = await web.window.navigator.credentials + .get(jsOptions as web.CredentialRequestOptions) + .toDart; + + if (credential == null) { + throw const PasskeyAssertionFailedException( + 'Credential retrieval returned null', + ); + } + + final pkCredential = credential as web.PublicKeyCredential; + final response = + pkCredential.response as web.AuthenticatorAssertionResponse; + + final result = { + 'id': _arrayBufferToBase64url(pkCredential.rawId), + 'rawId': _arrayBufferToBase64url(pkCredential.rawId), + 'type': 'public-key', + 'response': { + 'clientDataJSON': _arrayBufferToBase64url(response.clientDataJSON), + 'authenticatorData': _arrayBufferToBase64url( + response.authenticatorData, + ), + 'signature': _arrayBufferToBase64url(response.signature), + 'userHandle': response.userHandle != null + ? _arrayBufferToBase64url(response.userHandle!) + : null, + }, + 'clientExtensionResults': + {}, // Required by PasskeyGetResult.fromJson + 'authenticatorAttachment': 'platform', + }; + + return json.encode(result); + } on PasskeyException { + rethrow; + } on Object catch (error) { + throw _mapDomException(error, isCreate: false); + } + } + + /// Converts a base64url-encoded field in [map] to a JS ArrayBuffer + /// in place, which is required by the WebAuthn browser API. + void _convertField(Map map, String key) { + final value = map[key]; + if (value is String) { + map[key] = _base64urlToArrayBuffer(value); + } + } + + /// Converts `user.id` from base64url to ArrayBuffer. + void _convertUserIdField(Map options) { + final user = options['user']; + if (user is Map) { + _convertField(user, 'id'); + } + } + + /// Converts credential ID fields in `excludeCredentials` or + /// `allowCredentials` from base64url to ArrayBuffer. + void _convertCredentialIds(Map options, String key) { + final credentials = options[key]; + if (credentials is List) { + for (final credential in credentials) { + if (credential is Map) { + _convertField(credential, 'id'); + } + } + } + } + + /// Decodes a base64url string to a JS ArrayBuffer. + JSArrayBuffer _base64urlToArrayBuffer(String base64url) { + final bytes = base64UrlDecode(base64url); + return Uint8List.fromList(bytes).buffer.toJS; + } + + /// Encodes a JS ArrayBuffer to a base64url string. + String _arrayBufferToBase64url(JSArrayBuffer buffer) { + final bytes = buffer.toDart.asUint8List(); + return base64UrlEncode(bytes); + } + + /// Maps a JS DOMException (or other error) to the appropriate + /// [PasskeyException] subtype. + PasskeyException _mapDomException(Object error, {required bool isCreate}) { + final errorMessage = error.toString(); + String? domExceptionName; + + // Try to extract DOMException name from JS error. + // ignore: invalid_runtime_check_with_js_interop_types + if (error is JSObject) { + try { + final name = (error as web.DOMException).name; + domExceptionName = name; + } on Object { + // Not a DOMException, fall through. + } + } + + if (domExceptionName != null) { + switch (domExceptionName) { + case 'NotAllowedError': + case 'AbortError': + return PasskeyCancelledException( + 'User cancelled the passkey operation: $errorMessage', + ); + case 'SecurityError': + return PasskeyRpMismatchException( + 'Relying party mismatch: $errorMessage', + ); + case 'InvalidStateError': + case 'ConstraintError': + return PasskeyRegistrationFailedException( + 'Credential operation failed: $errorMessage', + ); + } + } + + // Default error based on operation type. + if (isCreate) { + return PasskeyRegistrationFailedException( + 'Failed to create credential: $errorMessage', + ); + } + return PasskeyAssertionFailedException( + 'Failed to get credential: $errorMessage', + ); + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_stub.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_stub.dart new file mode 100644 index 00000000000..979e96b39fe --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/model/webauthn/webauthn_credential_platform_stub.dart @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; +import 'package:amplify_core/amplify_core.dart'; + +/// Stub implementation of [WebAuthnCredentialPlatform] for platforms that +/// do not support passkeys. +/// +/// Returns `false` for [isPasskeySupported] and throws +/// [PasskeyNotSupportedException] for credential operations. +class WebAuthnCredentialPlatformImpl implements WebAuthnCredentialPlatform { + /// Creates a stub [WebAuthnCredentialPlatform]. + const WebAuthnCredentialPlatformImpl(); + + @override + Future isPasskeySupported() async => false; + + @override + Future createCredential(String optionsJson) async { + throw const PasskeyNotSupportedException( + 'Passkeys are not supported on this platform', + ); + } + + @override + Future getCredential(String optionsJson) async { + throw const PasskeyNotSupportedException( + 'Passkeys are not supported on this platform', + ); + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/cognito_webauthn_client.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/cognito_webauthn_client.dart new file mode 100644 index 00000000000..9fd70005823 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/cognito_webauthn_client.dart @@ -0,0 +1,297 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// Raw HTTP client for Cognito WebAuthn API operations not covered by +/// the Smithy-generated SDK. +library; + +import 'dart:convert'; + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/passkey_types.dart'; +import 'package:amplify_auth_cognito_dart/src/sdk/sdk_exception.dart'; +import 'package:aws_common/aws_common.dart'; + +/// {@template amplify_auth_cognito_dart.sdk.cognito_webauthn_client} +/// Raw HTTP client for Cognito WebAuthn API operations not covered by +/// the Smithy-generated SDK. +/// +/// These operations use the AWS JSON 1.1 protocol against the Cognito +/// Identity Provider service endpoint. All operations are access-token +/// authorized (the token is sent in the JSON body, not as a header). +/// {@endtemplate} +class CognitoWebAuthnClient { + /// {@macro amplify_auth_cognito_dart.sdk.cognito_webauthn_client} + const CognitoWebAuthnClient({ + required String region, + required AWSHttpClient httpClient, + String? endpoint, + }) : _region = region, + _httpClient = httpClient, + _endpoint = endpoint; + + final String _region; + final AWSHttpClient _httpClient; + final String? _endpoint; + + /// The service endpoint URI. + Uri get _endpointUri { + if (_endpoint != null) { + final ep = _endpoint; + return ep.startsWith('http') ? Uri.parse(ep) : Uri.parse('https://$ep'); + } + return Uri.parse('https://cognito-idp.$_region.amazonaws.com/'); + } + + /// Makes a raw JSON 1.1 request to the Cognito Identity Provider service. + Future> _makeRequest({ + required String target, + required Map body, + }) async { + final bodyBytes = utf8.encode(json.encode(body)); + final request = AWSHttpRequest( + method: AWSHttpMethod.post, + uri: _endpointUri, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + 'X-Amz-Target': target, + AWSHeaders.cacheControl: 'no-store', + }, + body: bodyBytes, + ); + + final operation = _httpClient.send(request); + final response = await operation.response; + final responseBody = await response.decodeBody(); + + if (response.statusCode >= 200 && response.statusCode < 300) { + if (responseBody.isEmpty) { + return {}; + } + return json.decode(responseBody) as Map; + } + + // Parse error response + _handleErrorResponse(responseBody); + } + + /// Parses an error response and throws the appropriate exception. + Never _handleErrorResponse(String responseBody) { + Map? errorJson; + try { + errorJson = json.decode(responseBody) as Map; + } on Object { + // If we can't parse the error body, throw a generic exception. + throw UnknownServiceException('Service returned an error: $responseBody'); + } + + final errorType = errorJson['__type'] as String?; + final message = + errorJson['message'] as String? ?? + errorJson['Message'] as String? ?? + 'An unknown error occurred'; + + // Extract the short error type name (may be fully qualified). + final shortType = (errorType?.contains('#') ?? false) + ? errorType!.split('#').last + : errorType; + + throw switch (shortType) { + 'WebAuthnNotEnabledException' => UnknownServiceException( + message, + recoverySuggestion: + 'Enable passkeys in your Cognito user pool settings.', + ), + 'WebAuthnOriginNotAllowedException' => UnknownServiceException( + message, + recoverySuggestion: + 'Verify the relying party origin is configured correctly ' + 'in your Cognito user pool.', + ), + 'WebAuthnCredentialNotSupportedException' => UnknownServiceException( + message, + recoverySuggestion: + 'The credential type is not supported. Try a different ' + 'authenticator.', + ), + 'WebAuthnChallengeNotFoundException' => UnknownServiceException( + message, + recoverySuggestion: + 'The registration challenge has expired. Start the ' + 'registration process again.', + ), + 'WebAuthnClientMismatchException' => UnknownServiceException( + message, + recoverySuggestion: + 'The client does not match the one used to start the ' + 'registration. Use the same client to complete registration.', + ), + 'LimitExceededException' => LimitExceededException(message), + 'NotAuthorizedException' => NotAuthorizedServiceException(message), + 'ForbiddenException' => ForbiddenException(message), + 'InternalErrorException' => InternalErrorException(message), + 'InvalidParameterException' => InvalidParameterException(message), + 'ResourceNotFoundException' => ResourceNotFoundException(message), + 'TooManyRequestsException' => TooManyRequestsException(message), + _ => UnknownServiceException(message), + }; + } + + /// Starts WebAuthn credential registration by requesting creation options + /// from Cognito. + /// + /// Requires a valid access token from an authenticated user session. + /// Returns [PasskeyCreateOptions] containing the credential creation + /// options to pass to the platform WebAuthn API. + Future startWebAuthnRegistration({ + required String accessToken, + }) async { + final responseJson = await _makeRequest( + target: 'AWSCognitoIdentityProviderService.StartWebAuthnRegistration', + body: {'AccessToken': accessToken}, + ); + + final credentialCreationOptions = + responseJson['CredentialCreationOptions'] as Map; + return PasskeyCreateOptions.fromJson(credentialCreationOptions); + } + + /// Completes WebAuthn credential registration by sending the platform + /// ceremony result to Cognito. + /// + /// [accessToken] must be the same session as [startWebAuthnRegistration]. + /// [credential] is the [PasskeyCreateResult] from the platform ceremony. + Future completeWebAuthnRegistration({ + required String accessToken, + required PasskeyCreateResult credential, + }) async { + await _makeRequest( + target: 'AWSCognitoIdentityProviderService.CompleteWebAuthnRegistration', + body: {'AccessToken': accessToken, 'Credential': credential.toJson()}, + ); + } + + /// Lists WebAuthn credentials registered for the authenticated user. + /// + /// Supports pagination via [maxResults] and [nextToken]. + /// Returns a list of credential descriptions and an optional pagination + /// token. + Future listWebAuthnCredentials({ + required String accessToken, + int? maxResults, + String? nextToken, + }) async { + final body = {'AccessToken': accessToken}; + if (maxResults != null) { + body['MaxResults'] = maxResults; + } + if (nextToken != null) { + body['NextToken'] = nextToken; + } + + final responseJson = await _makeRequest( + target: 'AWSCognitoIdentityProviderService.ListWebAuthnCredentials', + body: body, + ); + + final credentialsJson = responseJson['Credentials'] as List? ?? []; + final credentials = credentialsJson + .map( + (e) => + WebAuthnCredentialDescription.fromJson(e as Map), + ) + .toList(); + + return ListWebAuthnCredentialsResult( + credentials: credentials, + nextToken: responseJson['NextToken'] as String?, + ); + } + + /// Deletes a WebAuthn credential from the user's account. + /// + /// [credentialId] is the credential ID from [listWebAuthnCredentials]. + Future deleteWebAuthnCredential({ + required String accessToken, + required String credentialId, + }) async { + await _makeRequest( + target: 'AWSCognitoIdentityProviderService.DeleteWebAuthnCredential', + body: {'AccessToken': accessToken, 'CredentialId': credentialId}, + ); + } +} + +/// Result of listing WebAuthn credentials. +class ListWebAuthnCredentialsResult { + /// Creates a [ListWebAuthnCredentialsResult]. + const ListWebAuthnCredentialsResult({ + required this.credentials, + this.nextToken, + }); + + /// The list of WebAuthn credential descriptions. + final List credentials; + + /// Pagination token for fetching more results, or null if no more. + final String? nextToken; +} + +/// Description of a registered WebAuthn credential. +class WebAuthnCredentialDescription { + /// Creates a [WebAuthnCredentialDescription]. + const WebAuthnCredentialDescription({ + required this.credentialId, + required this.relyingPartyId, + required this.createdAt, + this.friendlyCredentialName, + this.authenticatorAttachment, + this.authenticatorTransports, + }); + + /// Creates a [WebAuthnCredentialDescription] from a JSON map. + /// + /// The `CreatedAt` field is expected to be a Unix timestamp in seconds. + factory WebAuthnCredentialDescription.fromJson(Map json) { + final createdAtValue = json['CreatedAt']; + final DateTime createdAt; + if (createdAtValue is num) { + createdAt = DateTime.fromMillisecondsSinceEpoch( + (createdAtValue.toDouble() * 1000).toInt(), + isUtc: true, + ); + } else { + createdAt = DateTime.parse(createdAtValue as String); + } + + return WebAuthnCredentialDescription( + credentialId: json['CredentialId'] as String, + relyingPartyId: json['RelyingPartyId'] as String, + createdAt: createdAt, + friendlyCredentialName: json['FriendlyCredentialName'] as String?, + authenticatorAttachment: json['AuthenticatorAttachment'] as String?, + authenticatorTransports: + (json['AuthenticatorTransports'] as List?) + ?.map((e) => e as String) + .toList(), + ); + } + + /// The credential ID. + final String credentialId; + + /// The relying party identifier (typically the domain). + final String relyingPartyId; + + /// The date and time when the credential was created. + final DateTime createdAt; + + /// The friendly name for the credential, if set. + final String? friendlyCredentialName; + + /// The authenticator attachment modality (`'platform'` or + /// `'cross-platform'`). + final String? authenticatorAttachment; + + /// Transport hints for the credential (e.g. `'internal'`, `'usb'`). + final List? authenticatorTransports; +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/sdk_bridge.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/sdk_bridge.dart index 298c5315e55..2e7efbab770 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/sdk_bridge.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/sdk/sdk_bridge.dart @@ -38,6 +38,8 @@ extension ChallengeNameTypeBridge on ChallengeNameType { AuthSignInStep.continueSignInWithFirstFactorSelection, ChallengeNameType.password || ChallengeNameType.passwordSrp => AuthSignInStep.confirmSignInWithPassword, + ChallengeNameType.webAuthn => + AuthSignInStep.confirmSignInWithCustomChallenge, ChallengeNameType.adminNoSrpAuth || ChallengeNameType.passwordVerifier || ChallengeNameType.devicePasswordVerifier || diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/sign_in_state_machine.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/sign_in_state_machine.dart index 3e009fb3bd3..6b7a1350e63 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/sign_in_state_machine.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/sign_in_state_machine.dart @@ -385,6 +385,9 @@ final class SignInStateMachine ), ChallengeNameType.passwordSrp when hasUserResponse => createPasswordSrpRequest(event), + ChallengeNameType.webAuthn => createWebAuthnAssertionRequest( + challengeParameters, + ), _ => null, }; } @@ -951,6 +954,42 @@ final class SignInStateMachine }); } + /// Creates a [RespondToAuthChallengeRequest] for a WEB_AUTHN challenge + /// by invoking the platform WebAuthn bridge to perform an assertion ceremony. + @protected + Future createWebAuthnAssertionRequest( + BuiltMap challengeParameters, + ) async { + final optionsJson = + challengeParameters[CognitoConstants + .challengeParamCredentialRequestOptions]; + if (optionsJson == null || optionsJson.isEmpty) { + throw const PasskeyAssertionFailedException( + 'CREDENTIAL_REQUEST_OPTIONS not found in challenge parameters', + ); + } + + final platform = get(); + if (platform == null) { + throw const PasskeyNotSupportedException( + 'No WebAuthn platform bridge is registered. ' + 'Ensure passkey support is configured for this platform.', + ); + } + + final credentialJson = await platform.getCredential(optionsJson); + + return RespondToAuthChallengeRequest.build((b) { + b + ..challengeName = ChallengeNameType.webAuthn + ..challengeResponses.addAll({ + CognitoConstants.challengeParamUsername: cognitoUsername, + CognitoConstants.challengeParamCredential: credentialJson, + }) + ..clientId = _authOutputs.userPoolClientId; + }); + } + /// Responds to a TOTP challenge. @protected Future createSoftwareTokenMfaRequest( diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/util/base64url_encode.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/util/base64url_encode.dart new file mode 100644 index 00000000000..e8a2e4bdf2b --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/util/base64url_encode.dart @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; + +/// Encodes [bytes] to a base64url string without padding. +/// +/// Uses the standard base64url alphabet (RFC 4648) and strips trailing +/// `=` padding characters, matching the encoding used by WebAuthn and +/// Cognito APIs. +String base64UrlEncode(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); +} + +/// Decodes a base64url-encoded [encoded] string to bytes. +/// +/// Normalizes padding by appending `=` characters as needed so the +/// input length is a multiple of 4 before decoding with the standard +/// base64url codec. +List base64UrlDecode(String encoded) { + final remainder = encoded.length % 4; + if (remainder != 0) { + encoded = encoded.padRight(encoded.length + (4 - remainder), '='); + } + return base64Url.decode(encoded); +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/model/cognito_webauthn_credential_test.dart b/packages/auth/amplify_auth_cognito_dart/test/model/cognito_webauthn_credential_test.dart new file mode 100644 index 00000000000..5dcf9f649de --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/model/cognito_webauthn_credential_test.dart @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:test/test.dart'; + +void main() { + group('CognitoWebAuthnCredential', () { + test('fromDescription maps all 6 fields correctly', () { + final description = WebAuthnCredentialDescription( + credentialId: 'test-cred-id', + relyingPartyId: 'example.com', + createdAt: DateTime.utc(2024, 1, 15, 10, 30), + friendlyCredentialName: 'My iPhone', + authenticatorAttachment: 'platform', + authenticatorTransports: ['internal', 'usb'], + ); + + final credential = CognitoWebAuthnCredential.fromDescription(description); + + expect(credential.credentialId, 'test-cred-id'); + expect(credential.relyingPartyId, 'example.com'); + expect(credential.createdAt, DateTime.utc(2024, 1, 15, 10, 30)); + expect(credential.friendlyName, 'My iPhone'); + expect(credential.authenticatorAttachment, 'platform'); + expect(credential.authenticatorTransports, ['internal', 'usb']); + }); + + test('toJson returns all fields', () { + final credential = CognitoWebAuthnCredential( + credentialId: 'test-cred-id', + relyingPartyId: 'example.com', + createdAt: DateTime.utc(2024, 1, 15, 10, 30), + friendlyName: 'My iPhone', + authenticatorAttachment: 'platform', + authenticatorTransports: const ['internal'], + ); + + final json = credential.toJson(); + + expect(json['credentialId'], 'test-cred-id'); + expect(json['relyingPartyId'], 'example.com'); + expect(json['createdAt'], '2024-01-15T10:30:00.000Z'); + expect(json['friendlyName'], 'My iPhone'); + expect(json['authenticatorAttachment'], 'platform'); + expect(json['authenticatorTransports'], ['internal']); + }); + + test('equals and hashCode work correctly', () { + final cred1 = CognitoWebAuthnCredential( + credentialId: 'test-id', + relyingPartyId: 'example.com', + createdAt: DateTime.utc(2024, 1, 15), + ); + final cred2 = CognitoWebAuthnCredential( + credentialId: 'test-id', + relyingPartyId: 'example.com', + createdAt: DateTime.utc(2024, 1, 15), + ); + final cred3 = CognitoWebAuthnCredential( + credentialId: 'different-id', + relyingPartyId: 'example.com', + createdAt: DateTime.utc(2024, 1, 15), + ); + + expect(cred1, equals(cred2)); + expect(cred1.hashCode, equals(cred2.hashCode)); + expect(cred1, isNot(equals(cred3))); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/model/passkey_types_test.dart b/packages/auth/amplify_auth_cognito_dart/test/model/passkey_types_test.dart new file mode 100644 index 00000000000..604d93d2a3a --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/model/passkey_types_test.dart @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:test/test.dart'; + +void main() { + group('PasskeyCreateOptions', () { + test('fromJson/toJson round-trip preserves all fields', () { + final json = { + 'challenge': 'test-challenge-base64url', + 'rp': {'id': 'example.com', 'name': 'Example App'}, + 'user': { + 'id': 'user-id-base64url', + 'name': 'user@example.com', + 'displayName': 'Test User', + }, + 'pubKeyCredParams': [ + {'type': 'public-key', 'alg': -7}, + {'type': 'public-key', 'alg': -257}, + ], + 'timeout': 60000, + 'attestation': 'none', + }; + + final options = PasskeyCreateOptions.fromJson(json); + expect(options.challenge, 'test-challenge-base64url'); + expect(options.rp.id, 'example.com'); + expect(options.user.name, 'user@example.com'); + expect(options.pubKeyCredParams.length, 2); + expect(options.timeout, 60000); + + final serialized = options.toJson(); + expect(serialized['challenge'], 'test-challenge-base64url'); + expect(serialized['rp'], {'id': 'example.com', 'name': 'Example App'}); + expect(serialized['timeout'], 60000); + }); + }); + + group('PasskeyGetOptions', () { + test('fromJson/toJson round-trip preserves all fields', () { + final json = { + 'challenge': 'test-challenge-base64url', + 'rpId': 'example.com', + 'timeout': 60000, + 'userVerification': 'required', + }; + + final options = PasskeyGetOptions.fromJson(json); + expect(options.challenge, 'test-challenge-base64url'); + expect(options.rpId, 'example.com'); + expect(options.timeout, 60000); + expect(options.userVerification, 'required'); + + final serialized = options.toJson(); + expect(serialized['challenge'], 'test-challenge-base64url'); + expect(serialized['rpId'], 'example.com'); + expect(serialized['timeout'], 60000); + expect(serialized['userVerification'], 'required'); + }); + }); + + group('PasskeyCreateResult', () { + test('fromJson/toJson round-trip preserves all fields', () { + final json = { + 'id': 'credential-id-base64url', + 'rawId': 'credential-id-base64url', + 'type': 'public-key', + 'response': { + 'clientDataJSON': 'client-data-base64url', + 'attestationObject': 'attestation-object-base64url', + }, + 'clientExtensionResults': {}, + }; + + final result = PasskeyCreateResult.fromJson(json); + expect(result.id, 'credential-id-base64url'); + expect(result.type, 'public-key'); + expect(result.response.clientDataJSON, 'client-data-base64url'); + expect(result.response.attestationObject, 'attestation-object-base64url'); + + final serialized = result.toJson(); + expect(serialized['id'], 'credential-id-base64url'); + expect(serialized['rawId'], 'credential-id-base64url'); + expect(serialized['type'], 'public-key'); + expect( + (serialized['response'] as Map)['clientDataJSON'], + 'client-data-base64url', + ); + }); + + test('toJson preserves W3C field names', () { + const result = PasskeyCreateResult( + id: 'cred-id', + rawId: 'cred-id', + type: 'public-key', + response: PasskeyAttestationResponse( + clientDataJSON: 'client-data', + attestationObject: 'attestation', + ), + clientExtensionResults: {}, + ); + + final json = result.toJson(); + expect(json['clientDataJSON'], isNull); // on outer object + expect((json['response'] as Map)['clientDataJSON'], 'client-data'); + expect((json['response'] as Map)['attestationObject'], 'attestation'); + }); + }); + + group('PasskeyGetResult', () { + test('fromJson/toJson round-trip preserves all fields', () { + final json = { + 'id': 'credential-id-base64url', + 'rawId': 'credential-id-base64url', + 'type': 'public-key', + 'response': { + 'clientDataJSON': 'client-data-base64url', + 'authenticatorData': 'authenticator-data-base64url', + 'signature': 'signature-base64url', + }, + 'clientExtensionResults': {}, + }; + + final result = PasskeyGetResult.fromJson(json); + expect(result.id, 'credential-id-base64url'); + expect(result.type, 'public-key'); + expect(result.response.clientDataJSON, 'client-data-base64url'); + expect(result.response.authenticatorData, 'authenticator-data-base64url'); + expect(result.response.signature, 'signature-base64url'); + + final serialized = result.toJson(); + expect(serialized['id'], 'credential-id-base64url'); + expect(serialized['type'], 'public-key'); + expect( + (serialized['response'] as Map)['signature'], + 'signature-base64url', + ); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_stub_test.dart b/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_stub_test.dart new file mode 100644 index 00000000000..4297f404d88 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_stub_test.dart @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform_stub.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebAuthnCredentialPlatformImpl (stub)', () { + late WebAuthnCredentialPlatformImpl stub; + + setUp(() { + stub = const WebAuthnCredentialPlatformImpl(); + }); + + test('isPasskeySupported returns false', () async { + expect(await stub.isPasskeySupported(), isFalse); + }); + + test('createCredential throws PasskeyNotSupportedException', () async { + expect( + () => stub.createCredential('{"options": "test"}'), + throwsA(isA()), + ); + }); + + test('getCredential throws PasskeyNotSupportedException', () async { + expect( + () => stub.getCredential('{"options": "test"}'), + throwsA(isA()), + ); + }); + + test('createCredential exception message is descriptive', () async { + try { + await stub.createCredential('{}'); + fail('Expected PasskeyNotSupportedException'); + } on PasskeyNotSupportedException catch (e) { + expect(e.message, contains('not supported')); + } + }); + + test('getCredential exception message is descriptive', () async { + try { + await stub.getCredential('{}'); + fail('Expected PasskeyNotSupportedException'); + } on PasskeyNotSupportedException catch (e) { + expect(e.message, contains('not supported')); + } + }); + + test('stub can be constructed with const', () { + // Verify const constructor works (compile-time check) + const stub1 = WebAuthnCredentialPlatformImpl(); + const stub2 = WebAuthnCredentialPlatformImpl(); + expect(identical(stub1, stub2), isTrue); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_test.dart b/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_test.dart new file mode 100644 index 00000000000..b89ca21b39e --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/model/webauthn_credential_platform_test.dart @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebAuthnCredentialPlatform', () { + test('interface can be implemented by mock', () { + final mock = MockWebAuthnPlatform(); + expect(mock, isA()); + }); + + test('createCredential has correct signature', () async { + final mock = MockWebAuthnPlatform(); + final result = await mock.createCredential('{}'); + expect(result, isA()); + }); + + test('getCredential has correct signature', () async { + final mock = MockWebAuthnPlatform(); + final result = await mock.getCredential('{}'); + expect(result, isA()); + }); + + test('isPasskeySupported has correct signature', () async { + final mock = MockWebAuthnPlatform(); + final result = await mock.isPasskeySupported(); + expect(result, isA()); + }); + + test('mock fulfills interface contract', () { + final mock = MockWebAuthnPlatform(); + expect(mock.createCredential, isA()); + expect(mock.getCredential, isA()); + expect(mock.isPasskeySupported, isA()); + }); + }); +} + +class MockWebAuthnPlatform implements WebAuthnCredentialPlatform { + @override + Future createCredential(String optionsJson) async { + return '{"id":"mock-credential-id","type":"public-key"}'; + } + + @override + Future getCredential(String optionsJson) async { + return '{"id":"mock-credential-id","type":"public-key"}'; + } + + @override + Future isPasskeySupported() async { + return true; + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/test/util/base64url_encode_test.dart b/packages/auth/amplify_auth_cognito_dart/test/util/base64url_encode_test.dart new file mode 100644 index 00000000000..f1105525a27 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_dart/test/util/base64url_encode_test.dart @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:convert'; + +import 'package:amplify_auth_cognito_dart/src/util/base64url_encode.dart'; +import 'package:test/test.dart'; + +void main() { + group('base64UrlEncode', () { + test('encodes bytes without padding', () { + final bytes = utf8.encode('test'); + final encoded = base64UrlEncode(bytes); + expect(encoded, 'dGVzdA'); + expect(encoded.contains('='), isFalse); + }); + + test('handles various byte lengths', () { + // Length that would normally require 1 padding char + final bytes1 = utf8.encode('ab'); + expect(base64UrlEncode(bytes1), 'YWI'); + expect(base64UrlEncode(bytes1).contains('='), isFalse); + + // Length that would normally require 2 padding chars + final bytes2 = utf8.encode('a'); + expect(base64UrlEncode(bytes2), 'YQ'); + expect(base64UrlEncode(bytes2).contains('='), isFalse); + }); + }); + + group('base64UrlDecode', () { + test('decodes base64url strings without padding', () { + final decoded = base64UrlDecode('dGVzdA'); + expect(utf8.decode(decoded), 'test'); + }); + + test('normalizes padding and decodes correctly', () { + // Should handle strings that need padding normalization + expect(utf8.decode(base64UrlDecode('YWI')), 'ab'); + expect(utf8.decode(base64UrlDecode('YQ')), 'a'); + }); + + test('handles strings with padding already present', () { + final decoded = base64UrlDecode('dGVzdA=='); + expect(utf8.decode(decoded), 'test'); + }); + }); + + group('base64url round-trip', () { + test('encode/decode round-trip preserves data', () { + const original = 'Hello, WebAuthn World!'; + final bytes = utf8.encode(original); + final encoded = base64UrlEncode(bytes); + final decoded = base64UrlDecode(encoded); + final result = utf8.decode(decoded); + + expect(result, original); + }); + + test('handles binary data', () { + final bytes = List.generate(256, (i) => i); + final encoded = base64UrlEncode(bytes); + final decoded = base64UrlDecode(encoded); + + expect(decoded, bytes); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/lib/common/mock_webauthn.dart b/packages/auth/amplify_auth_cognito_test/lib/common/mock_webauthn.dart new file mode 100644 index 00000000000..befa3a497e0 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/lib/common/mock_webauthn.dart @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/src/model/webauthn/webauthn_credential_platform.dart'; + +/// Mock implementation of WebAuthnCredentialPlatform for testing. +/// +/// Each method takes an optional callback. If provided, the callback is invoked; +/// otherwise, [UnimplementedError] is thrown. This follows the same pattern +/// as MockCognitoIdentityProviderClient in mock_clients.dart. +class MockWebAuthnCredentialPlatform implements WebAuthnCredentialPlatform { + /// Creates a [MockWebAuthnCredentialPlatform] with optional callbacks. + MockWebAuthnCredentialPlatform({ + Future Function(String)? createCredential, + Future Function(String)? getCredential, + Future Function()? isPasskeySupported, + }) : _createCredential = createCredential, + _getCredential = getCredential, + _isPasskeySupported = isPasskeySupported; + + final Future Function(String)? _createCredential; + final Future Function(String)? _getCredential; + final Future Function()? _isPasskeySupported; + + @override + Future createCredential(String optionsJson) { + if (_createCredential == null) { + throw UnimplementedError('createCredential not mocked'); + } + return _createCredential(optionsJson); + } + + @override + Future getCredential(String optionsJson) { + if (_getCredential == null) { + throw UnimplementedError('getCredential not mocked'); + } + return _getCredential(optionsJson); + } + + @override + Future isPasskeySupported() { + if (_isPasskeySupported == null) { + throw UnimplementedError('isPasskeySupported not mocked'); + } + return _isPasskeySupported(); + } +} diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/associate_webauthn_credential_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/associate_webauthn_credential_test.dart new file mode 100644 index 00000000000..6e35dab92bf --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/associate_webauthn_credential_test.dart @@ -0,0 +1,242 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: invalid_use_of_protected_member, close_sinks + +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_test/common/matchers.dart'; +import 'package:amplify_auth_cognito_test/common/mock_config.dart'; +import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; +import 'package:amplify_auth_cognito_test/common/mock_webauthn.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; +import 'package:aws_common/src/http/mock.dart'; +import 'package:test/test.dart'; + +void main() { + final userPoolKeys = CognitoUserPoolKeys(mockConfig.auth!.userPoolClientId!); + final identityPoolKeys = CognitoIdentityPoolKeys( + mockConfig.auth!.identityPoolId!, + ); + + late AmplifyAuthCognitoDart plugin; + late CognitoAuthStateMachine stateMachine; + late MockSecureStorage secureStorage; + + late StreamController hubEventsController; + + final testAuthRepo = AmplifyAuthProviderRepository(); + + group('AmplifyAuthCognitoDart', () { + setUp(() async { + secureStorage = MockSecureStorage(); + SecureStorageInterface storageFactory(scope) => secureStorage; + + stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + + plugin = AmplifyAuthCognitoDart(secureStorageFactory: storageFactory) + ..stateMachine = stateMachine; + + hubEventsController = StreamController(); + Amplify.Hub.listen(HubChannel.Auth, hubEventsController.add); + }); + + tearDown(() async { + Amplify.Hub.close(); + await Amplify.reset(); + }); + + group('associateWebAuthnCredential', () { + test('throws when signed out', () async { + // Arrange: configure plugin but don't seed storage (no authenticated user) + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert + expect( + () => plugin.associateWebAuthnCredential(), + throwsSignedOutException, + ); + }); + + test('orchestrates start -> platform ceremony -> complete', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client for Cognito WebAuthn API calls + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final target = request.headers['X-Amz-Target']; + json.decode(utf8.decode(request.bodyBytes)) as Map; + + if (target == + 'AWSCognitoIdentityProviderService.StartWebAuthnRegistration') { + // Return StartWebAuthnRegistration response with creation options + final responseBody = { + 'CredentialCreationOptions': { + 'challenge': 'Y2hhbGxlbmdl', + 'rp': {'id': 'test.example.com', 'name': 'Test'}, + 'user': { + 'id': 'dXNlcjE', + 'name': 'testuser', + 'displayName': 'Test User', + }, + 'pubKeyCredParams': [ + {'type': 'public-key', 'alg': -7}, + ], + 'timeout': 60000, + 'attestation': 'none', + }, + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + } else if (target == + 'AWSCognitoIdentityProviderService.CompleteWebAuthnRegistration') { + // Return success (empty body) + return AWSHttpResponse(statusCode: 200, body: utf8.encode('{}')); + } + throw UnimplementedError('Unexpected target: $target'); + }); + + stateMachine.addInstance(mockHttpClient); + + // Mock platform that returns a credential + final mockPlatform = MockWebAuthnCredentialPlatform( + createCredential: (optionsJson) async { + // Return a valid PasskeyCreateResult JSON + return json.encode({ + 'id': 'Y3JlZGVudGlhbElk', + 'rawId': 'Y3JlZGVudGlhbElk', + 'type': 'public-key', + 'response': { + 'clientDataJSON': 'Y2xpZW50RGF0YQ', + 'attestationObject': 'YXR0ZXN0YXRpb25PYmplY3Q', + }, + 'clientExtensionResults': {}, + }); + }, + ); + stateMachine.addInstance(mockPlatform); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert: should complete without exception + await plugin.associateWebAuthnCredential(); + }); + + test('propagates PasskeyCancelledException from platform', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client for StartWebAuthnRegistration + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final responseBody = { + 'CredentialCreationOptions': { + 'challenge': 'Y2hhbGxlbmdl', + 'rp': {'id': 'test.example.com', 'name': 'Test'}, + 'user': { + 'id': 'dXNlcjE', + 'name': 'testuser', + 'displayName': 'Test User', + }, + 'pubKeyCredParams': [ + {'type': 'public-key', 'alg': -7}, + ], + 'timeout': 60000, + 'attestation': 'none', + }, + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + }); + stateMachine.addInstance(mockHttpClient); + + // Mock platform that throws cancelled exception + final mockPlatform = MockWebAuthnCredentialPlatform( + createCredential: (_) async => + throw const PasskeyCancelledException('User cancelled'), + ); + stateMachine.addInstance(mockPlatform); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert: exception should propagate + expect( + () => plugin.associateWebAuthnCredential(), + throwsA(isA()), + ); + }); + + test('propagates platform not supported error', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client for StartWebAuthnRegistration + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final responseBody = { + 'CredentialCreationOptions': { + 'challenge': 'Y2hhbGxlbmdl', + 'rp': {'id': 'test.example.com', 'name': 'Test'}, + 'user': { + 'id': 'dXNlcjE', + 'name': 'testuser', + 'displayName': 'Test User', + }, + 'pubKeyCredParams': [ + {'type': 'public-key', 'alg': -7}, + ], + 'timeout': 60000, + 'attestation': 'none', + }, + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + }); + stateMachine.addInstance(mockHttpClient); + + // Don't inject platform (null) + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert: should throw PasskeyNotSupportedException + expect( + () => plugin.associateWebAuthnCredential(), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/delete_webauthn_credential_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/delete_webauthn_credential_test.dart new file mode 100644 index 00000000000..cd6572141eb --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/delete_webauthn_credential_test.dart @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: invalid_use_of_protected_member, close_sinks + +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_test/common/matchers.dart'; +import 'package:amplify_auth_cognito_test/common/mock_config.dart'; +import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; +import 'package:aws_common/src/http/mock.dart'; +import 'package:test/test.dart'; + +void main() { + final userPoolKeys = CognitoUserPoolKeys(mockConfig.auth!.userPoolClientId!); + final identityPoolKeys = CognitoIdentityPoolKeys( + mockConfig.auth!.identityPoolId!, + ); + + late AmplifyAuthCognitoDart plugin; + late CognitoAuthStateMachine stateMachine; + late MockSecureStorage secureStorage; + + late StreamController hubEventsController; + + final testAuthRepo = AmplifyAuthProviderRepository(); + + group('AmplifyAuthCognitoDart', () { + setUp(() async { + secureStorage = MockSecureStorage(); + SecureStorageInterface storageFactory(scope) => secureStorage; + + stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + + plugin = AmplifyAuthCognitoDart(secureStorageFactory: storageFactory) + ..stateMachine = stateMachine; + + hubEventsController = StreamController(); + Amplify.Hub.listen(HubChannel.Auth, hubEventsController.add); + }); + + tearDown(() async { + Amplify.Hub.close(); + await Amplify.reset(); + }); + + group('deleteWebAuthnCredential', () { + test('throws when signed out', () async { + // Arrange: configure plugin but don't seed storage + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert + expect( + () => plugin.deleteWebAuthnCredential('test-cred-id'), + throwsSignedOutException, + ); + }); + + test('deletes credential successfully', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client for DeleteWebAuthnCredential + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final bodyMap = + json.decode(utf8.decode(request.bodyBytes)) + as Map; + expect(bodyMap['CredentialId'], 'test-cred-id'); + + return AWSHttpResponse(statusCode: 200, body: utf8.encode('{}')); + }); + stateMachine.addInstance(mockHttpClient); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert: should complete without exception + await plugin.deleteWebAuthnCredential('test-cred-id'); + }); + + test('throws when credential not found', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client returning 404 ResourceNotFoundException + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final errorBody = { + '__type': 'ResourceNotFoundException', + 'message': 'Credential not found', + }; + return AWSHttpResponse( + statusCode: 404, + body: utf8.encode(json.encode(errorBody)), + ); + }); + stateMachine.addInstance(mockHttpClient); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert: should throw exception + expect( + () => plugin.deleteWebAuthnCredential('nonexistent-cred-id'), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/is_passkey_supported_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/is_passkey_supported_test.dart new file mode 100644 index 00000000000..026acdca7bc --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/is_passkey_supported_test.dart @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: invalid_use_of_protected_member, close_sinks + +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_test/common/mock_config.dart'; +import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; +import 'package:amplify_auth_cognito_test/common/mock_webauthn.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; +import 'package:test/test.dart'; + +void main() { + late AmplifyAuthCognitoDart plugin; + late CognitoAuthStateMachine stateMachine; + late MockSecureStorage secureStorage; + + late StreamController hubEventsController; + + final testAuthRepo = AmplifyAuthProviderRepository(); + + group('AmplifyAuthCognitoDart', () { + setUp(() async { + secureStorage = MockSecureStorage(); + SecureStorageInterface storageFactory(scope) => secureStorage; + + stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + + plugin = AmplifyAuthCognitoDart(secureStorageFactory: storageFactory) + ..stateMachine = stateMachine; + + hubEventsController = StreamController(); + Amplify.Hub.listen(HubChannel.Auth, hubEventsController.add); + }); + + tearDown(() async { + Amplify.Hub.close(); + await Amplify.reset(); + }); + + group('isPasskeySupported', () { + test( + 'returns true when platform bridge available and supported', + () async { + // Arrange: inject mock platform that reports supported + final mockPlatform = MockWebAuthnCredentialPlatform( + isPasskeySupported: () async => true, + ); + stateMachine.addInstance(mockPlatform); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final result = await plugin.isPasskeySupported(); + + // Assert + expect(result, isTrue); + }, + ); + + test('returns false when platform bridge unavailable', () async { + // Arrange: don't inject platform (null) + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final result = await plugin.isPasskeySupported(); + + // Assert + expect(result, isFalse); + }); + + test('returns false when platform reports not supported', () async { + // Arrange: inject mock platform that reports not supported + final mockPlatform = MockWebAuthnCredentialPlatform( + isPasskeySupported: () async => false, + ); + stateMachine.addInstance(mockPlatform); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final result = await plugin.isPasskeySupported(); + + // Assert + expect(result, isFalse); + }); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/test/plugin/list_webauthn_credentials_test.dart b/packages/auth/amplify_auth_cognito_test/test/plugin/list_webauthn_credentials_test.dart new file mode 100644 index 00000000000..587682129e5 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/test/plugin/list_webauthn_credentials_test.dart @@ -0,0 +1,220 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: invalid_use_of_protected_member, close_sinks + +import 'dart:async'; +import 'dart:convert'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart' + hide InternalErrorException; +import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_test/common/matchers.dart'; +import 'package:amplify_auth_cognito_test/common/mock_config.dart'; +import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; +import 'package:aws_common/src/http/mock.dart'; +import 'package:test/test.dart'; + +void main() { + final userPoolKeys = CognitoUserPoolKeys(mockConfig.auth!.userPoolClientId!); + final identityPoolKeys = CognitoIdentityPoolKeys( + mockConfig.auth!.identityPoolId!, + ); + + late AmplifyAuthCognitoDart plugin; + late CognitoAuthStateMachine stateMachine; + late MockSecureStorage secureStorage; + + late StreamController hubEventsController; + + final testAuthRepo = AmplifyAuthProviderRepository(); + + group('AmplifyAuthCognitoDart', () { + setUp(() async { + secureStorage = MockSecureStorage(); + SecureStorageInterface storageFactory(scope) => secureStorage; + + stateMachine = CognitoAuthStateMachine()..addInstance(secureStorage); + + plugin = AmplifyAuthCognitoDart(secureStorageFactory: storageFactory) + ..stateMachine = stateMachine; + + hubEventsController = StreamController(); + Amplify.Hub.listen(HubChannel.Auth, hubEventsController.add); + }); + + tearDown(() async { + Amplify.Hub.close(); + await Amplify.reset(); + }); + + group('listWebAuthnCredentials', () { + test('throws when signed out', () async { + // Arrange: configure plugin but don't seed storage + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act & Assert + expect( + () => plugin.listWebAuthnCredentials(), + throwsSignedOutException, + ); + }); + + test('returns credentials with all fields mapped', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client for ListWebAuthnCredentials + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final responseBody = { + 'Credentials': [ + { + 'CredentialId': 'cred-1', + 'RelyingPartyId': 'test.example.com', + 'CreatedAt': 1710000000.0, + 'FriendlyCredentialName': 'My Passkey', + 'AuthenticatorAttachment': 'platform', + 'AuthenticatorTransports': ['internal'], + }, + ], + 'NextToken': null, + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + }); + stateMachine.addInstance(mockHttpClient); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final credentials = await plugin.listWebAuthnCredentials(); + + // Assert: verify all 6 fields are mapped + expect(credentials, hasLength(1)); + final credential = credentials.first; + expect(credential.credentialId, 'cred-1'); + expect(credential.relyingPartyId, 'test.example.com'); + expect( + credential.createdAt, + DateTime.fromMillisecondsSinceEpoch(1710000000000, isUtc: true), + ); + expect(credential.friendlyName, 'My Passkey'); + expect(credential.authenticatorAttachment, 'platform'); + expect(credential.authenticatorTransports, ['internal']); + }); + + test('handles pagination across multiple pages', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + var callCount = 0; + // Mock HTTP client with pagination + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final bodyMap = + json.decode(utf8.decode(request.bodyBytes)) + as Map; + callCount++; + + if (callCount == 1) { + // First page with NextToken + final responseBody = { + 'Credentials': [ + { + 'CredentialId': 'cred-1', + 'RelyingPartyId': 'test.example.com', + 'CreatedAt': 1710000000.0, + }, + ], + 'NextToken': 'token-for-page-2', + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + } else { + // Second page without NextToken + expect(bodyMap['NextToken'], 'token-for-page-2'); + final responseBody = { + 'Credentials': [ + { + 'CredentialId': 'cred-2', + 'RelyingPartyId': 'test.example.com', + 'CreatedAt': 1710000100.0, + }, + ], + 'NextToken': null, + }; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + } + }); + stateMachine.addInstance(mockHttpClient); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final credentials = await plugin.listWebAuthnCredentials(); + + // Assert: should have fetched both pages + expect(credentials, hasLength(2)); + expect(credentials[0].credentialId, 'cred-1'); + expect(credentials[1].credentialId, 'cred-2'); + expect(callCount, 2); + }); + + test('returns empty list when no credentials', () async { + // Arrange: seed storage for authenticated state + seedStorage( + secureStorage, + userPoolKeys: userPoolKeys, + identityPoolKeys: identityPoolKeys, + ); + + // Mock HTTP client returning empty credentials + final mockHttpClient = MockAWSHttpClient((request, isCancelled) async { + final responseBody = {'Credentials': [], 'NextToken': null}; + return AWSHttpResponse( + statusCode: 200, + body: utf8.encode(json.encode(responseBody)), + ); + }); + stateMachine.addInstance(mockHttpClient); + + await plugin.configure( + config: mockConfig, + authProviderRepo: testAuthRepo, + ); + + // Act + final credentials = await plugin.listWebAuthnCredentials(); + + // Assert + expect(credentials, isEmpty); + }); + }); + }); +} diff --git a/packages/auth/amplify_auth_cognito_test/test/state/sign_in_webauthn_test.dart b/packages/auth/amplify_auth_cognito_test/test/state/sign_in_webauthn_test.dart new file mode 100644 index 00000000000..28d9ba46ee4 --- /dev/null +++ b/packages/auth/amplify_auth_cognito_test/test/state/sign_in_webauthn_test.dart @@ -0,0 +1,446 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; +import 'package:amplify_auth_cognito_dart/src/flows/constants.dart'; +import 'package:amplify_auth_cognito_dart/src/model/sign_in_parameters.dart'; +import 'package:amplify_auth_cognito_dart/src/sdk/cognito_identity_provider.dart' + as cognito_idp; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_dart/src/state/state.dart'; +import 'package:amplify_auth_cognito_test/common/mock_clients.dart'; +import 'package:amplify_auth_cognito_test/common/mock_config.dart'; +import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; + +/// Mock implementation of [WebAuthnCredentialPlatform] for testing. +class MockWebAuthnCredentialPlatform implements WebAuthnCredentialPlatform { + MockWebAuthnCredentialPlatform({ + this.onGetCredential, + this.onCreateCredential, + this.onIsPasskeySupported, + }); + + final Future Function(String optionsJson)? onGetCredential; + final Future Function(String optionsJson)? onCreateCredential; + final Future Function()? onIsPasskeySupported; + + @override + Future getCredential(String optionsJson) => + onGetCredential!(optionsJson); + + @override + Future createCredential(String optionsJson) => + onCreateCredential!(optionsJson); + + @override + Future isPasskeySupported() => + onIsPasskeySupported?.call() ?? Future.value(true); +} + +/// Test credential request options JSON. +const testCredentialRequestOptions = + '{"challenge":"dGVzdC1jaGFsbGVuZ2U",' + '"rpId":"example.com",' + '"allowCredentials":[],' + '"timeout":60000,' + '"userVerification":"preferred"}'; + +/// Test credential assertion response JSON. +const testCredentialResponse = + '{"id":"credential-id",' + '"rawId":"Y3JlZGVudGlhbC1pZA",' + '"type":"public-key",' + '"response":{' + '"clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0",' + '"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA",' + '"signature":"MEUCIQDKg7m-jRDKvPIzSaR6SYMBjG3qPLCvkKqz_Ypfhnkm3Q",' + '"userHandle":"dXNlci1pZA"},' + '"clientExtensionResults":{}}'; + +void main() { + AWSLogger().logLevel = LogLevel.verbose; + + group('SignInStateMachine WebAuthn', () { + late CognitoAuthStateMachine stateMachine; + late SecureStorageInterface secureStorage; + + setUp(() { + secureStorage = MockSecureStorage(); + stateMachine = CognitoAuthStateMachine() + ..addInstance(secureStorage); + }); + + /// Helper to configure the state machine and wait for configuration. + Future configure() async { + stateMachine + .dispatch(ConfigurationEvent.configure(mockConfigUserPoolOnly)) + .ignore(); + await expectLater( + stateMachine.stream.whereType().firstWhere( + (event) => event is Configured || event is ConfigureFailure, + ), + completion(isA()), + ); + } + + group('direct WEB_AUTHN challenge', () { + test('completes sign-in without user interaction', () async { + await configure(); + + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.webAuthn, + challengeParameters: { + CognitoConstants.challengeParamCredentialRequestOptions: + testCredentialRequestOptions, + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + respondToAuthChallenge: expectAsync1((request) async { + expect( + request.challengeResponses?[CognitoConstants + .challengeParamCredential], + testCredentialResponse, + ); + expect( + request.challengeName, + cognito_idp.ChallengeNameType.webAuthn, + ); + return cognito_idp.RespondToAuthChallengeResponse( + authenticationResult: cognito_idp.AuthenticationResultType( + accessToken: accessToken.raw, + refreshToken: refreshToken, + idToken: idToken.raw, + ), + ); + }), + ); + + final mockPlatform = MockWebAuthnCredentialPlatform( + onGetCredential: expectAsync1((optionsJson) async { + expect(optionsJson, testCredentialRequestOptions); + return testCredentialResponse; + }), + ); + + stateMachine + ..addInstance(mockClient) + ..addInstance(mockPlatform); + + expect( + stateMachine + .accept( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .completed, + completion(isA()), + ); + }); + }); + + group('SELECT_CHALLENGE with WebAuthn', () { + test('includes webAuthn in available factors', () async { + await configure(); + + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.selectChallenge, + availableChallenges: [ + cognito_idp.ChallengeNameType.webAuthn, + cognito_idp.ChallengeNameType.password, + ], + challengeParameters: { + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + ); + + stateMachine.addInstance( + mockClient, + ); + + stateMachine + .dispatch( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .ignore(); + + final signInStateMachine = stateMachine.expect(SignInStateMachine.type); + + expect( + signInStateMachine.stream, + emitsThrough( + isA() + .having( + (s) => s.challengeName, + 'challengeName', + cognito_idp.ChallengeNameType.selectChallenge, + ) + .having( + (s) => s.allowedfirstFactorTypes, + 'allowedfirstFactorTypes', + containsAll([ + AuthFactorType.webAuthn, + AuthFactorType.password, + ]), + ), + ), + ); + }); + + test( + 'two-step SELECT_CHALLENGE -> WEB_AUTHN completes sign-in', + () async { + await configure(); + + var respondCallCount = 0; + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.selectChallenge, + availableChallenges: [ + cognito_idp.ChallengeNameType.webAuthn, + cognito_idp.ChallengeNameType.password, + ], + challengeParameters: { + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + respondToAuthChallenge: (request) async { + respondCallCount++; + if (respondCallCount == 1) { + // First call: SELECT_CHALLENGE answer + expect( + request.challengeName, + cognito_idp.ChallengeNameType.selectChallenge, + ); + expect( + request.challengeResponses?[CognitoConstants + .challengeParamAnswer], + 'WEB_AUTHN', + ); + return cognito_idp.RespondToAuthChallengeResponse( + challengeName: cognito_idp.ChallengeNameType.webAuthn, + challengeParameters: { + CognitoConstants.challengeParamCredentialRequestOptions: + testCredentialRequestOptions, + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session-2', + ); + } else { + // Second call: WEB_AUTHN credential response + expect( + request.challengeName, + cognito_idp.ChallengeNameType.webAuthn, + ); + expect( + request.challengeResponses?[CognitoConstants + .challengeParamCredential], + testCredentialResponse, + ); + return cognito_idp.RespondToAuthChallengeResponse( + authenticationResult: cognito_idp.AuthenticationResultType( + accessToken: accessToken.raw, + refreshToken: refreshToken, + idToken: idToken.raw, + ), + ); + } + }, + ); + + final mockPlatform = MockWebAuthnCredentialPlatform( + onGetCredential: expectAsync1((optionsJson) async { + return testCredentialResponse; + }), + ); + + stateMachine + ..addInstance(mockClient) + ..addInstance(mockPlatform); + + stateMachine + .dispatch( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .ignore(); + + final signInStateMachine = stateMachine.expect( + SignInStateMachine.type, + ); + + // Wait for SELECT_CHALLENGE state + await expectLater( + signInStateMachine.stream, + emitsThrough(isA()), + ); + + // Respond with WEB_AUTHN selection + expect( + stateMachine + .accept(const SignInRespondToChallenge(answer: 'WEB_AUTHN')) + .completed, + completion(isA()), + ); + }, + ); + }); + + group('error handling', () { + test('emits failure when user cancels WebAuthn ceremony', () async { + await configure(); + + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.webAuthn, + challengeParameters: { + CognitoConstants.challengeParamCredentialRequestOptions: + testCredentialRequestOptions, + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + ); + + final mockPlatform = MockWebAuthnCredentialPlatform( + onGetCredential: expectAsync1((optionsJson) async { + throw const PasskeyCancelledException('User cancelled'); + }), + ); + + stateMachine + ..addInstance(mockClient) + ..addInstance(mockPlatform); + + expect( + stateMachine + .accept( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .completed, + completion( + isA().having( + (state) => state.exception, + 'exception', + isA(), + ), + ), + ); + }); + + test('emits failure when WebAuthn platform is not registered', () async { + await configure(); + + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.webAuthn, + challengeParameters: { + CognitoConstants.challengeParamCredentialRequestOptions: + testCredentialRequestOptions, + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + ); + + // Intentionally NOT registering WebAuthnCredentialPlatform + stateMachine.addInstance( + mockClient, + ); + + expect( + stateMachine + .accept( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .completed, + completion( + isA().having( + (state) => state.exception, + 'exception', + isA(), + ), + ), + ); + }); + + test( + 'emits failure when CREDENTIAL_REQUEST_OPTIONS is missing', + () async { + await configure(); + + final mockClient = MockCognitoIdentityProviderClient( + initiateAuth: expectAsync1((_) async { + return cognito_idp.InitiateAuthResponse( + challengeName: cognito_idp.ChallengeNameType.webAuthn, + challengeParameters: { + // Missing CREDENTIAL_REQUEST_OPTIONS + CognitoConstants.challengeParamUsername: username, + }, + session: 'test-session', + ); + }), + ); + + final mockPlatform = MockWebAuthnCredentialPlatform( + onGetCredential: (_) async => testCredentialResponse, + ); + + stateMachine + ..addInstance(mockClient) + ..addInstance(mockPlatform); + + expect( + stateMachine + .accept( + SignInEvent.initiate( + authFlowType: AuthenticationFlowType.userAuth, + parameters: SignInParameters((p) => p..username = username), + ), + ) + .completed, + completion( + isA().having( + (state) => state.exception, + 'exception', + isA(), + ), + ), + ); + }, + ); + }); + }); +} diff --git a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart index 58ebf57a67a..2371337a856 100644 --- a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart +++ b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart @@ -16,6 +16,7 @@ import 'package:amplify_authenticator/src/l10n/auth_strings_resolver.dart'; import 'package:amplify_authenticator/src/l10n/authenticator_localizations.dart'; import 'package:amplify_authenticator/src/models/authenticator_builder.dart'; import 'package:amplify_authenticator/src/models/authenticator_exception.dart'; +import 'package:amplify_authenticator/src/models/passwordless_settings.dart'; import 'package:amplify_authenticator/src/models/totp_options.dart'; import 'package:amplify_authenticator/src/screens/authenticator_screen.dart'; import 'package:amplify_authenticator/src/screens/loading_screen.dart'; @@ -47,6 +48,7 @@ export 'src/enums/enums.dart' show AuthenticatorStep, AuthenticatorTextEnabledOverride, Gender; export 'src/l10n/auth_strings_resolver.dart' hide ButtonResolverKeyType; export 'src/models/authenticator_exception.dart'; +export 'src/models/passwordless_settings.dart'; export 'src/models/totp_options.dart'; export 'src/models/username_input.dart' show UsernameType, UsernameInput, UsernameSelection; @@ -78,6 +80,8 @@ export 'src/widgets/form.dart' ConfirmSignInMFAForm, ConfirmSignInNewPasswordForm, ContinueSignInWithMfaSelectionForm, + ContinueSignInWithFirstFactorSelectionForm, + PasskeyPromptForm, ContinueSignInWithMfaSetupSelectionForm, ContinueSignInWithTotpSetupForm, ContinueSignInWithEmailMfaSetupForm, @@ -317,6 +321,7 @@ class Authenticator extends StatefulWidget { this.initialStep = AuthenticatorStep.signIn, this.authenticatorBuilder, this.padding = const EdgeInsets.all(32), + this.passwordlessSettings, this.dialCodeOptions = const DialCodeOptions(), this.totpOptions, @visibleForTesting this.authBlocOverride, @@ -436,6 +441,10 @@ class Authenticator extends StatefulWidget { /// method. final AuthenticatorStep initialStep; + /// Settings for passwordless authentication, including passkey registration + /// prompts and hidden auth methods. + final PasswordlessSettings? passwordlessSettings; + /// {@macro amplify_authenticator_dial_code_options} final DialCodeOptions dialCodeOptions; @@ -488,6 +497,12 @@ class Authenticator extends StatefulWidget { ), ) ..add(DiagnosticsProperty('totpOptions', totpOptions)) + ..add( + DiagnosticsProperty( + 'passwordlessSettings', + passwordlessSettings, + ), + ) ..add( DiagnosticsProperty( 'mockAuthenticatorState', @@ -523,6 +538,7 @@ class _AuthenticatorState extends State { preferPrivateSession: widget.preferPrivateSession, initialStep: widget.initialStep, totpOptions: widget.totpOptions, + passwordlessSettings: widget.passwordlessSettings, )..add(const AuthLoad())); _authenticatorState = AuthenticatorState( _stateMachineBloc, @@ -717,6 +733,9 @@ class _AuthenticatorState extends State { ContinueSignInWithEmailMfaSetupForm(), confirmSignInWithTotpMfaCodeForm: ConfirmSignInMFAForm(), confirmSignInWithOtpCodeForm: ConfirmSignInMFAForm(), + continueSignInWithFirstFactorSelectionForm: + const ContinueSignInWithFirstFactorSelectionForm(), + passkeyPromptForm: const PasskeyPromptForm(), verifyUserForm: VerifyUserForm(), confirmVerifyUserForm: ConfirmVerifyUserForm(), child: widget.child, diff --git a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_bloc.dart b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_bloc.dart index a18b2eaa08b..a33315d0d13 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_bloc.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_bloc.dart @@ -26,6 +26,7 @@ class StateMachineBloc required this.preferPrivateSession, this.initialStep = AuthenticatorStep.signIn, this.totpOptions, + this.passwordlessSettings, }) : _authService = authService { _hubSubscription = _authService.hubEvents.listen(_mapHubEvent); final blocStream = _authEventStream.asyncExpand((event) async* { @@ -42,6 +43,10 @@ class StateMachineBloc final bool preferPrivateSession; final AuthenticatorStep initialStep; final TotpOptions? totpOptions; + final PasswordlessSettings? passwordlessSettings; + + /// Tracks whether the current sign-in flow originated from a sign-up. + bool _isSignUpFlow = false; @override String get runtimeTypeName => 'StateMachineBloc'; @@ -135,6 +140,10 @@ class StateMachineBloc yield const AuthenticatedState(); } else if (event is AuthResendSignUpCode) { yield* _resendSignUpCode(event.username); + } else if (event is AuthPasskeyRegister) { + yield* _registerPasskey(); + } else if (event is AuthPasskeySkip) { + yield const AuthenticatedState(); } } @@ -152,7 +161,8 @@ class StateMachineBloc // AuthenticatedState will be emitted by the bloc when the verification // is completed successfully. if (currentState is VerifyUserFlow || - currentState is AttributeVerificationSent) { + currentState is AttributeVerificationSent || + currentState is PasskeyPromptState) { break; } nextState = const AuthenticatedState(); @@ -262,18 +272,14 @@ class StateMachineBloc } yield* _checkUserVerification(); case AuthSignInStep.continueSignInWithFirstFactorSelection: + yield ContinueSignInWithFirstFactorSelection( + availableFactors: result.nextStep.availableFactors, + ); case AuthSignInStep.confirmSignInWithOtp: + _notifyCodeSent(result.nextStep.codeDeliveryDetails?.destination); + yield UnauthenticatedState.confirmSignInWithOtpCode; case AuthSignInStep.confirmSignInWithPassword: - // TODO(cadivus): Implement Passwordless Authenticator. See: - // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html#amazon-cognito-user-pools-authentication-flow-methods-passkey - // https://docs.amplify.aws/react/build-a-backend/auth/concepts/passwordless/#webauthn-passkey - _exceptionController.add( - AuthenticatorException( - 'Passwordless is not supported at this time. Please try again.', - showBanner: true, - ), - ); - yield* _changeScreen(initialStep); + yield UnauthenticatedState.confirmSignInNewPassword; } } on AuthNotAuthorizedException { /// The .failAuthentication flag available in the DefineAuthChallenge Lambda trigger @@ -374,6 +380,17 @@ class StateMachineBloc _emit(state); } } + case AuthSignInStep.continueSignInWithFirstFactorSelection: + _emit( + ContinueSignInWithFirstFactorSelection( + availableFactors: result.nextStep.availableFactors, + ), + ); + case AuthSignInStep.confirmSignInWithOtp: + _notifyCodeSent(result.nextStep.codeDeliveryDetails?.destination); + _emit(UnauthenticatedState.confirmSignInWithOtpCode); + case AuthSignInStep.confirmSignInWithPassword: + _emit(UnauthenticatedState.confirmSignInNewPassword); default: break; } @@ -389,6 +406,12 @@ class StateMachineBloc if (data is AuthUsernamePasswordSignInData) { final result = await _authService.signIn(data.username, data.password); await _processSignInResult(result, isSocialSignIn: false); + } else if (data is AuthPasswordlessSignInData) { + final result = await _authService.signInPasswordless( + data.username, + preferredFactor: data.preferredFactor, + ); + await _processSignInResult(result, isSocialSignIn: false); } else if (data is AuthSocialSignInData) { await _authService .signInWithProvider( @@ -435,6 +458,8 @@ class StateMachineBloc } Stream _checkUserVerification() async* { + final isSignUp = _isSignUpFlow; + _isSignUpFlow = false; try { final attributeVerificationStatus = await _authService .getAttributeVerificationStatus(); @@ -445,14 +470,75 @@ class StateMachineBloc if (verifiedAttributes.isEmpty && unverifiedAttributes.isNotEmpty) { yield VerifyUserFlow(unverifiedAttributeKeys: unverifiedAttributes); } else { + yield* _checkPasskeyRegistrationPrompt(isSignUp: isSignUp); + } + } on Exception catch (e) { + _exceptionController.add(AuthenticatorException(e, showBanner: false)); + yield const AuthenticatedState(); + } + } + + Stream _checkPasskeyRegistrationPrompt({ + required bool isSignUp, + }) async* { + try { + final promptConfig = passwordlessSettings?.passkeyRegistrationPrompts; + if (promptConfig == null) { + yield const AuthenticatedState(); + return; + } + + final shouldPrompt = isSignUp + ? promptConfig.isEnabledAfterSignUp + : promptConfig.isEnabledAfterSignIn; + + if (!shouldPrompt) { + yield const AuthenticatedState(); + return; + } + + // Check if passkeys are supported on this platform + try { + final supported = await Amplify.Auth.isPasskeySupported(); + if (!supported) { + yield const AuthenticatedState(); + return; + } + } on Exception { yield const AuthenticatedState(); + return; } + + // Check if user already has passkeys — skip prompt if they do + try { + final credentials = await Amplify.Auth.listWebAuthnCredentials(); + if (credentials.isNotEmpty) { + yield const AuthenticatedState(); + return; + } + } on Exception { + // listWebAuthnCredentials failed — still show the prompt + } + + // Show prompt + yield const PasskeyPromptState(); } on Exception catch (e) { + // If checking fails, proceed to authenticated (don't block sign-in) _exceptionController.add(AuthenticatorException(e, showBanner: false)); yield const AuthenticatedState(); } } + Stream _registerPasskey() async* { + yield const PasskeyPromptState(isRegistering: true); + try { + await Amplify.Auth.associateWebAuthnCredential(); + yield const AuthenticatedState(); + } on Exception catch (e) { + yield PasskeyPromptState(errorMessage: e.toString()); + } + } + Stream _signUp(AuthSignUpData data) async* { try { final result = await _authService.signUp( @@ -466,6 +552,7 @@ class StateMachineBloc _notifyCodeSent(result.nextStep.codeDeliveryDetails?.destination); yield UnauthenticatedState.confirmSignUp; case AuthSignUpStep.done: + _isSignUpFlow = true; final authSignInData = AuthUsernamePasswordSignInData( username: data.username, password: data.password, diff --git a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_data.dart b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_data.dart index 75ec7d5a9b7..8d7d99b9aa4 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_data.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_data.dart @@ -23,6 +23,16 @@ class AuthSocialSignInData extends AuthSignInData { final AuthProvider provider; } +class AuthPasswordlessSignInData extends AuthSignInData { + const AuthPasswordlessSignInData({ + required this.username, + this.preferredFactor = AuthFactorType.webAuthn, + }); + + final String username; + final AuthFactorType preferredFactor; +} + ///Sign Up Data class AuthSignUpData { const AuthSignUpData({ diff --git a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_event.dart b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_event.dart index 444e05fe735..39af6edc5a4 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_event.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/blocs/auth/auth_event.dart @@ -87,3 +87,15 @@ class AuthResendSignUpCode extends AuthEvent { final String username; } + +/// Event dispatched when the user taps "Create a passkey" or "Set up another passkey" +/// on the passkey registration prompt screen. +class AuthPasskeyRegister extends AuthEvent { + const AuthPasskeyRegister(); +} + +/// Event dispatched when the user taps "Continue without a passkey" or "Continue" +/// on the passkey registration prompt screen to proceed to authenticated state. +class AuthPasskeySkip extends AuthEvent { + const AuthPasskeySkip(); +} diff --git a/packages/authenticator/amplify_authenticator/lib/src/enums/authenticator_step.dart b/packages/authenticator/amplify_authenticator/lib/src/enums/authenticator_step.dart index 8fcc406563f..c382a4e62bc 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/enums/authenticator_step.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/enums/authenticator_step.dart @@ -102,6 +102,16 @@ enum AuthenticatorStep { /// The user has initiated verification of an account recovery means /// (email, phone), and needs to provide a verification code. confirmVerifyUser, + + /// The user is on the Continue Sign In with First Factor Selection step. + /// + /// The sign-in is not complete and the user must select a first-factor + /// method. + continueSignInWithFirstFactorSelection, + + /// The user is being prompted to register a passkey after successful + /// sign-in or sign-up. + passkeyPrompt, } const validInitialAuthenticatorSteps = [ diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/button_resolver.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/button_resolver.dart index 6075d2e0761..a761b874d5d 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/button_resolver.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/button_resolver.dart @@ -25,6 +25,14 @@ enum ButtonResolverKeyType { backTo, skip, copyKey, + signInWithPasskey, + signInWithPassword, + signInWithEmail, + signInWithSms, + createPasskey, + continueWithoutPasskey, + setupAnotherPasskey, + otherSignInOptions, } class ButtonResolverKey { @@ -66,6 +74,30 @@ class ButtonResolverKey { ); static const skip = ButtonResolverKey._(ButtonResolverKeyType.skip); static const copyKey = ButtonResolverKey._(ButtonResolverKeyType.copyKey); + static const signInWithPasskey = ButtonResolverKey._( + ButtonResolverKeyType.signInWithPasskey, + ); + static const signInWithPassword = ButtonResolverKey._( + ButtonResolverKeyType.signInWithPassword, + ); + static const signInWithEmail = ButtonResolverKey._( + ButtonResolverKeyType.signInWithEmail, + ); + static const signInWithSms = ButtonResolverKey._( + ButtonResolverKeyType.signInWithSms, + ); + static const createPasskey = ButtonResolverKey._( + ButtonResolverKeyType.createPasskey, + ); + static const continueWithoutPasskey = ButtonResolverKey._( + ButtonResolverKeyType.continueWithoutPasskey, + ); + static const setupAnotherPasskey = ButtonResolverKey._( + ButtonResolverKeyType.setupAnotherPasskey, + ); + static const otherSignInOptions = ButtonResolverKey._( + ButtonResolverKeyType.otherSignInOptions, + ); @override String toString() => type.name; @@ -169,6 +201,46 @@ class ButtonResolver extends Resolver { return AuthenticatorLocalizations.buttonsOf(context).copyKey; } + /// Label of button to sign in with a passkey. + String signInWithPasskey(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).signInWithPasskey; + } + + /// Label of button to sign in with a password. + String signInWithPassword(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).signInWithPassword; + } + + /// Label of button to sign in with email. + String signInWithEmail(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).signInWithEmail; + } + + /// Label of button to sign in with SMS. + String signInWithSms(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).signInWithSms; + } + + /// Label of button to create a passkey. + String createPasskey(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).createPasskey; + } + + /// Label of button to continue without a passkey. + String continueWithoutPasskey(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).continueWithoutPasskey; + } + + /// Label of button to set up another passkey. + String setupAnotherPasskey(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).setupAnotherPasskey; + } + + /// Label of button to show other sign-in options. + String otherSignInOptions(BuildContext context) { + return AuthenticatorLocalizations.buttonsOf(context).otherSignInOptions; + } + @override String resolve(BuildContext context, ButtonResolverKey key) { switch (key.type) { @@ -208,6 +280,22 @@ class ButtonResolver extends Resolver { return skip(context); case ButtonResolverKeyType.copyKey: return copyKey(context); + case ButtonResolverKeyType.signInWithPasskey: + return signInWithPasskey(context); + case ButtonResolverKeyType.signInWithPassword: + return signInWithPassword(context); + case ButtonResolverKeyType.signInWithEmail: + return signInWithEmail(context); + case ButtonResolverKeyType.signInWithSms: + return signInWithSms(context); + case ButtonResolverKeyType.createPasskey: + return createPasskey(context); + case ButtonResolverKeyType.continueWithoutPasskey: + return continueWithoutPasskey(context); + case ButtonResolverKeyType.setupAnotherPasskey: + return setupAnotherPasskey(context); + case ButtonResolverKeyType.otherSignInOptions: + return otherSignInOptions(context); } } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations.dart index 39f04e46a6a..877638d0bb3 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations.dart @@ -204,6 +204,54 @@ abstract class AuthenticatorButtonLocalizations { /// In en, this message translates to: /// **'Sign In with {provider, select, google{Google} facebook{Facebook} amazon{Amazon} apple{Apple} other{ERROR}}'** String signInWith(String provider); + + /// Label of button to sign in using a passkey + /// + /// In en, this message translates to: + /// **'Sign in with passkey'** + String get signInWithPasskey; + + /// Label of button to sign in using a password + /// + /// In en, this message translates to: + /// **'Sign in with password'** + String get signInWithPassword; + + /// Label of button to sign in using email OTP + /// + /// In en, this message translates to: + /// **'Sign in with email'** + String get signInWithEmail; + + /// Label of button to sign in using SMS OTP + /// + /// In en, this message translates to: + /// **'Sign in with SMS'** + String get signInWithSms; + + /// Label of button to create a new passkey + /// + /// In en, this message translates to: + /// **'Create a passkey'** + String get createPasskey; + + /// Label of button to skip passkey registration + /// + /// In en, this message translates to: + /// **'Continue without a passkey'** + String get continueWithoutPasskey; + + /// Label of button to set up an additional passkey + /// + /// In en, this message translates to: + /// **'Set up another passkey'** + String get setupAnotherPasskey; + + /// Label of button to show alternative sign-in methods + /// + /// In en, this message translates to: + /// **'Other sign-in options'** + String get otherSignInOptions; } class _AuthenticatorButtonLocalizationsDelegate diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations_en.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations_en.dart index d3b1ea2231b..b668b30be65 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations_en.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/button_localizations_en.dart @@ -85,4 +85,28 @@ class AuthenticatorButtonLocalizationsEn }); return 'Sign In with $temp0'; } + + @override + String get signInWithPasskey => 'Sign in with passkey'; + + @override + String get signInWithPassword => 'Sign in with password'; + + @override + String get signInWithEmail => 'Sign in with email'; + + @override + String get signInWithSms => 'Sign in with SMS'; + + @override + String get createPasskey => 'Create a passkey'; + + @override + String get continueWithoutPasskey => 'Continue without a passkey'; + + @override + String get setupAnotherPasskey => 'Set up another passkey'; + + @override + String get otherSignInOptions => 'Other sign-in options'; } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations.dart index 563c9eab27d..4e058a40305 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations.dart @@ -120,6 +120,24 @@ abstract class AuthenticatorMessageLocalizations { /// In en, this message translates to: /// **'Copy to clipboard failed.'** String get copyFailed; + + /// The message displayed when passkey registration fails + /// + /// In en, this message translates to: + /// **'Passkey registration failed. Please try again or continue without a passkey.'** + String get passkeyRegistrationFailed; + + /// The message displayed when a passkey sign-in ceremony fails + /// + /// In en, this message translates to: + /// **'Passkey sign-in failed. Please try again or choose another method.'** + String get passkeyCeremonyFailed; + + /// Descriptive text explaining what passkeys are + /// + /// In en, this message translates to: + /// **'Passkeys let you sign in securely using your device\'s built-in authentication.'** + String get passkeyPromptDescription; } class _AuthenticatorMessageLocalizationsDelegate diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations_en.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations_en.dart index aa14e2f9b22..b55210458be 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations_en.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/message_localizations_en.dart @@ -21,4 +21,16 @@ class AuthenticatorMessageLocalizationsEn @override String get copyFailed => 'Copy to clipboard failed.'; + + @override + String get passkeyRegistrationFailed => + 'Passkey registration failed. Please try again or continue without a passkey.'; + + @override + String get passkeyCeremonyFailed => + 'Passkey sign-in failed. Please try again or choose another method.'; + + @override + String get passkeyPromptDescription => + "Passkeys let you sign in securely using your device's built-in authentication."; } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations.dart index 51f2488e3a0..a8b5a1b8444 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations.dart @@ -174,6 +174,24 @@ abstract class AuthenticatorTitleLocalizations { /// In en, this message translates to: /// **'Account recovery requires verified contact information'** String get verifyUser; + + /// Title of the first-factor selection step + /// + /// In en, this message translates to: + /// **'Choose how to sign in'** + String get continueSignInWithFirstFactorSelection; + + /// Title of the passkey registration prompt step + /// + /// In en, this message translates to: + /// **'Sign in faster with a passkey'** + String get passkeyPrompt; + + /// Title shown after a passkey is successfully created + /// + /// In en, this message translates to: + /// **'Passkey created successfully'** + String get passkeyCreatedSuccess; } class _AuthenticatorTitleLocalizationsDelegate diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations_en.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations_en.dart index 938f974e9f2..bdf540b7436 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations_en.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/title_localizations_en.dart @@ -50,4 +50,13 @@ class AuthenticatorTitleLocalizationsEn @override String get verifyUser => 'Account recovery requires verified contact information'; + + @override + String get continueSignInWithFirstFactorSelection => 'Choose how to sign in'; + + @override + String get passkeyPrompt => 'Sign in faster with a passkey'; + + @override + String get passkeyCreatedSuccess => 'Passkey created successfully'; } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/message_resolver.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/message_resolver.dart index ced74d13774..fb6d0a8600c 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/message_resolver.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/message_resolver.dart @@ -6,7 +6,14 @@ import 'package:amplify_authenticator/src/l10n/authenticator_localizations.dart' import 'package:amplify_authenticator/src/l10n/resolver.dart'; import 'package:flutter/material.dart'; -enum MessageResolverKeyType { codeSent, copySucceeded, copyFailed } +enum MessageResolverKeyType { + codeSent, + copySucceeded, + copyFailed, + passkeyRegistrationFailed, + passkeyCeremonyFailed, + passkeyPromptDescription, +} class MessageResolverKey { const MessageResolverKey._(this.type, this.destination); @@ -15,6 +22,19 @@ class MessageResolverKey { : this._(MessageResolverKeyType.codeSent, destination); final MessageResolverKeyType type; final String? destination; + + static const passkeyRegistrationFailed = MessageResolverKey._( + MessageResolverKeyType.passkeyRegistrationFailed, + null, + ); + static const passkeyCeremonyFailed = MessageResolverKey._( + MessageResolverKeyType.passkeyCeremonyFailed, + null, + ); + static const passkeyPromptDescription = MessageResolverKey._( + MessageResolverKeyType.passkeyPromptDescription, + null, + ); } /// The resolver class for messages @@ -43,6 +63,25 @@ class MessageResolver extends Resolver { return AuthenticatorLocalizations.messagesOf(context).copyFailed; } + /// The message displayed when passkey registration fails. + String passkeyRegistrationFailed(BuildContext context) { + return AuthenticatorLocalizations.messagesOf( + context, + ).passkeyRegistrationFailed; + } + + /// The message displayed when a passkey sign-in ceremony fails. + String passkeyCeremonyFailed(BuildContext context) { + return AuthenticatorLocalizations.messagesOf(context).passkeyCeremonyFailed; + } + + /// Descriptive text explaining what passkeys are. + String passkeyPromptDescription(BuildContext context) { + return AuthenticatorLocalizations.messagesOf( + context, + ).passkeyPromptDescription; + } + /// Resolves error messages for AuthenticatorException instances String error(BuildContext context, AuthenticatorException exception) { if (exception is CognitoAuthenticatorException) { @@ -67,6 +106,12 @@ class MessageResolver extends Resolver { return copySucceeded(context); case MessageResolverKeyType.copyFailed: return copyFailed(context); + case MessageResolverKeyType.passkeyRegistrationFailed: + return passkeyRegistrationFailed(context); + case MessageResolverKeyType.passkeyCeremonyFailed: + return passkeyCeremonyFailed(context); + case MessageResolverKeyType.passkeyPromptDescription: + return passkeyPromptDescription(context); } } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/buttons/buttons_en.arb b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/buttons/buttons_en.arb index 08b1c694775..35e3b507a62 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/buttons/buttons_en.arb +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/buttons/buttons_en.arb @@ -84,5 +84,37 @@ "description": "The social sign-in provider" } } + }, + "signInWithPasskey": "Sign in with passkey", + "@signInWithPasskey": { + "description": "Label of button to sign in using a passkey" + }, + "signInWithPassword": "Sign in with password", + "@signInWithPassword": { + "description": "Label of button to sign in using a password" + }, + "signInWithEmail": "Sign in with email", + "@signInWithEmail": { + "description": "Label of button to sign in using email OTP" + }, + "signInWithSms": "Sign in with SMS", + "@signInWithSms": { + "description": "Label of button to sign in using SMS OTP" + }, + "createPasskey": "Create a passkey", + "@createPasskey": { + "description": "Label of button to create a new passkey" + }, + "continueWithoutPasskey": "Continue without a passkey", + "@continueWithoutPasskey": { + "description": "Label of button to skip passkey registration" + }, + "setupAnotherPasskey": "Set up another passkey", + "@setupAnotherPasskey": { + "description": "Label of button to set up an additional passkey" + }, + "otherSignInOptions": "Other sign-in options", + "@otherSignInOptions": { + "description": "Label of button to show alternative sign-in methods" } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/messages/messages_en.arb b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/messages/messages_en.arb index e43fd3f8f55..b04b00ef61e 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/messages/messages_en.arb +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/messages/messages_en.arb @@ -23,5 +23,17 @@ "copyFailed": "Copy to clipboard failed.", "@copyFailed": { "description": "The message that is displayed after a value failed to copy to the clipboard" + }, + "passkeyRegistrationFailed": "Passkey registration failed. Please try again or continue without a passkey.", + "@passkeyRegistrationFailed": { + "description": "The message displayed when passkey registration fails" + }, + "passkeyCeremonyFailed": "Passkey sign-in failed. Please try again or choose another method.", + "@passkeyCeremonyFailed": { + "description": "The message displayed when a passkey sign-in ceremony fails" + }, + "passkeyPromptDescription": "Passkeys let you sign in securely using your device's built-in authentication.", + "@passkeyPromptDescription": { + "description": "Descriptive text explaining what passkeys are" } -} +} diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/titles/titles_en.arb b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/titles/titles_en.arb index 71f87943225..13c7f616816 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/titles/titles_en.arb +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/titles/titles_en.arb @@ -40,5 +40,17 @@ "verifyUser": "Account recovery requires verified contact information", "@verifyUser": { "description": "Title of the Verify and Confirm Verify User s and forms" + }, + "continueSignInWithFirstFactorSelection": "Choose how to sign in", + "@continueSignInWithFirstFactorSelection": { + "description": "Title of the first-factor selection step" + }, + "passkeyPrompt": "Sign in faster with a passkey", + "@passkeyPrompt": { + "description": "Title of the passkey registration prompt step" + }, + "passkeyCreatedSuccess": "Passkey created successfully", + "@passkeyCreatedSuccess": { + "description": "Title shown after a passkey is successfully created" } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/title_resolver.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/title_resolver.dart index e81ec9a3291..401d05e13a4 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/title_resolver.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/title_resolver.dart @@ -89,6 +89,23 @@ class TitleResolver extends Resolver { return AuthenticatorLocalizations.titlesOf(context).verifyUser; } + /// The title for the first-factor selection Widget. + String continueSignInWithFirstFactorSelection(BuildContext context) { + return AuthenticatorLocalizations.titlesOf( + context, + ).continueSignInWithFirstFactorSelection; + } + + /// The title for the passkey registration prompt Widget. + String passkeyPrompt(BuildContext context) { + return AuthenticatorLocalizations.titlesOf(context).passkeyPrompt; + } + + /// The title shown after a passkey is successfully created. + String passkeyCreatedSuccess(BuildContext context) { + return AuthenticatorLocalizations.titlesOf(context).passkeyCreatedSuccess; + } + @override String resolve(BuildContext context, AuthenticatorStep key) { switch (key) { @@ -119,6 +136,10 @@ class TitleResolver extends Resolver { case AuthenticatorStep.verifyUser: case AuthenticatorStep.confirmVerifyUser: return verifyUser(context); + case AuthenticatorStep.continueSignInWithFirstFactorSelection: + return continueSignInWithFirstFactorSelection(context); + case AuthenticatorStep.passkeyPrompt: + return passkeyPrompt(context); case AuthenticatorStep.loading: case AuthenticatorStep.onboarding: case AuthenticatorStep.signIn: diff --git a/packages/authenticator/amplify_authenticator/lib/src/models/passwordless_settings.dart b/packages/authenticator/amplify_authenticator/lib/src/models/passwordless_settings.dart new file mode 100644 index 00000000000..19d880cc0ec --- /dev/null +++ b/packages/authenticator/amplify_authenticator/lib/src/models/passwordless_settings.dart @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_flutter/amplify_flutter.dart'; + +/// Controls whether passkey registration prompts are shown. +enum PasskeyPromptBehavior { + /// Always prompt for passkey registration. + always, + + /// Never prompt for passkey registration. + never, +} + +/// Configuration for when passkey registration prompts are displayed. +class PasskeyRegistrationPrompts { + /// Creates a [PasskeyRegistrationPrompts] with the given behaviors. + const PasskeyRegistrationPrompts({ + this.afterSignIn = PasskeyPromptBehavior.always, + this.afterSignUp = PasskeyPromptBehavior.always, + }); + + /// Prompts are enabled after both sign-in and sign-up. + const PasskeyRegistrationPrompts.enabled() + : afterSignIn = PasskeyPromptBehavior.always, + afterSignUp = PasskeyPromptBehavior.always; + + /// Prompts are disabled after both sign-in and sign-up. + const PasskeyRegistrationPrompts.disabled() + : afterSignIn = PasskeyPromptBehavior.never, + afterSignUp = PasskeyPromptBehavior.never; + + /// Whether to prompt for passkey registration after sign-in. + final PasskeyPromptBehavior afterSignIn; + + /// Whether to prompt for passkey registration after sign-up. + final PasskeyPromptBehavior afterSignUp; + + /// Whether passkey registration prompt is enabled after sign-in. + bool get isEnabledAfterSignIn => afterSignIn == PasskeyPromptBehavior.always; + + /// Whether passkey registration prompt is enabled after sign-up. + bool get isEnabledAfterSignUp => afterSignUp == PasskeyPromptBehavior.always; +} + +/// Settings for passwordless authentication in the Authenticator. +class PasswordlessSettings { + /// Creates [PasswordlessSettings] with the given configuration. + const PasswordlessSettings({ + this.availableAuthMethods, + this.hiddenAuthMethods, + this.preferredAuthMethod, + this.passkeyRegistrationPrompts, + }); + + /// All auth methods available for sign-in. When set, the sign-in screen + /// shows the [preferredAuthMethod] as the primary action and an + /// "Other sign-in options" link for the rest. + /// + /// Defaults to `[webAuthn, password, emailOtp, smsOtp]` when + /// [preferredAuthMethod] is set and this is null. + final List? availableAuthMethods; + + /// Auth factor types to hide from the first-factor selection screen. + final List? hiddenAuthMethods; + + /// The preferred auth method to use as the primary sign-in action. + final AuthFactorType? preferredAuthMethod; + + /// Configuration for post-auth passkey registration prompts. + final PasskeyRegistrationPrompts? passkeyRegistrationPrompts; + + /// Returns the methods to show in "Other sign-in options", i.e. all + /// available methods except the preferred one and any hidden ones. + List get otherAuthMethods { + final all = + availableAuthMethods ?? + [ + AuthFactorType.webAuthn, + AuthFactorType.password, + AuthFactorType.emailOtp, + AuthFactorType.smsOtp, + ]; + return all + .where((m) => m != preferredAuthMethod) + .where((m) => !(hiddenAuthMethods?.contains(m) ?? false)) + .toList(); + } +} diff --git a/packages/authenticator/amplify_authenticator/lib/src/screens/authenticator_screen.dart b/packages/authenticator/amplify_authenticator/lib/src/screens/authenticator_screen.dart index 92b3d2ed839..8cbb744a0d2 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/screens/authenticator_screen.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/screens/authenticator_screen.dart @@ -103,6 +103,8 @@ class AuthenticatorScreen extends StatelessAuthenticatorComponent { case AuthenticatorStep.confirmSignInWithOtpCode: case AuthenticatorStep.continueSignInWithEmailMfaSetup: case AuthenticatorStep.continueSignInWithMfaSetupSelection: + case AuthenticatorStep.continueSignInWithFirstFactorSelection: + case AuthenticatorStep.passkeyPrompt: child = _FormWrapperView(step: step); case AuthenticatorStep.loading: throw StateError('Invalid step: $this'); @@ -309,6 +311,8 @@ extension on AuthenticatorStep { case AuthenticatorStep.confirmSignInWithOtpCode: case AuthenticatorStep.continueSignInWithEmailMfaSetup: case AuthenticatorStep.continueSignInWithMfaSetupSelection: + case AuthenticatorStep.continueSignInWithFirstFactorSelection: + case AuthenticatorStep.passkeyPrompt: throw StateError('Invalid step: $this'); } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/services/amplify_auth_service.dart b/packages/authenticator/amplify_authenticator/lib/src/services/amplify_auth_service.dart index 4e3825a0964..fc8dc19969e 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/services/amplify_auth_service.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/services/amplify_auth_service.dart @@ -11,6 +11,11 @@ import 'package:collection/collection.dart'; abstract class AuthService { Future signIn(String username, String password); + Future signInPasswordless( + String username, { + AuthFactorType preferredFactor = AuthFactorType.webAuthn, + }); + Future signInWithProvider( AuthProvider provider, { required bool preferPrivateSession, @@ -105,6 +110,25 @@ class AmplifyAuthService return result; } + @override + Future signInPasswordless( + String username, { + AuthFactorType preferredFactor = AuthFactorType.webAuthn, + }) async { + final result = await _withUserAgent( + () => Amplify.Auth.signIn( + username: username, + options: SignInOptions( + pluginOptions: CognitoSignInPluginOptions( + authFlowType: AuthenticationFlowType.userAuth, + preferredFirstFactor: preferredFactor, + ), + ), + ), + ); + return result; + } + @override Future signInWithProvider( AuthProvider provider, { diff --git a/packages/authenticator/amplify_authenticator/lib/src/state/auth_state.dart b/packages/authenticator/amplify_authenticator/lib/src/state/auth_state.dart index a5bfea18e00..cbe729c079e 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/state/auth_state.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/state/auth_state.dart @@ -134,6 +134,47 @@ class ContinueSignInWithMfaSetupSelection extends UnauthenticatedState { String get runtimeTypeName => 'ContinueSignInWithMfaSetupSelection'; } +class ContinueSignInWithFirstFactorSelection extends UnauthenticatedState { + const ContinueSignInWithFirstFactorSelection({ + Set? availableFactors, + }) : availableFactors = availableFactors ?? const {}, + super(step: AuthenticatorStep.continueSignInWithFirstFactorSelection); + + final Set availableFactors; + + @override + List get props => [step, availableFactors]; + + @override + String get runtimeTypeName => 'ContinueSignInWithFirstFactorSelection'; +} + +class PasskeyPromptState extends UnauthenticatedState { + const PasskeyPromptState({ + this.isRegistering = false, + this.isSuccess = false, + this.errorMessage, + this.registeredCredentials = const [], + }) : super(step: AuthenticatorStep.passkeyPrompt); + + final bool isRegistering; + final bool isSuccess; + final String? errorMessage; + final List registeredCredentials; + + @override + List get props => [ + step, + isRegistering, + isSuccess, + errorMessage, + registeredCredentials, + ]; + + @override + String get runtimeTypeName => 'PasskeyPromptState'; +} + class ContinueSignInTotpSetup extends UnauthenticatedState { const ContinueSignInTotpSetup(this.totpSetupDetails, this.totpSetupUri) : super(step: AuthenticatorStep.continueSignInWithTotpSetup); diff --git a/packages/authenticator/amplify_authenticator/lib/src/state/authenticator_state.dart b/packages/authenticator/amplify_authenticator/lib/src/state/authenticator_state.dart index 248c3c598c4..faa425251eb 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/state/authenticator_state.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/state/authenticator_state.dart @@ -520,11 +520,61 @@ class AuthenticatorState extends ChangeNotifier { TextInput.finishAutofillContext(shouldSave: true); - final signIn = AuthUsernamePasswordSignInData( - username: _username.trim(), - password: _password.trim(), + final preferred = _authBloc.passwordlessSettings?.preferredAuthMethod; + + if (preferred != null && preferred != AuthFactorType.password) { + final signIn = AuthPasswordlessSignInData( + username: _username.trim(), + preferredFactor: preferred, + ); + _authBloc.add(AuthSignIn(signIn)); + } else { + final signIn = AuthUsernamePasswordSignInData( + username: _username.trim(), + password: _password.trim(), + ); + _authBloc.add(AuthSignIn(signIn)); + } + await nextBlocEvent(); + _setIsBusy(false); + } + + /// Sign in with username and password, bypassing any passwordless preference. + Future signInWithPassword() async { + if (!_formKey.currentState!.validate()) { + return; + } + _setIsBusy(true); + TextInput.finishAutofillContext(shouldSave: true); + _authBloc.add( + AuthSignIn( + AuthUsernamePasswordSignInData( + username: _username.trim(), + password: _password.trim(), + ), + ), + ); + await nextBlocEvent(); + _setIsBusy(false); + } + + /// Sign in with a specific passwordless factor (webAuthn, emailOtp, smsOtp). + /// + /// Only validates that a username is present — password validation is skipped + /// since passwordless methods don't require one. + Future signInWithFactor(AuthFactorType factor) async { + if (_username.trim().isEmpty) { + return; + } + _setIsBusy(true); + _authBloc.add( + AuthSignIn( + AuthPasswordlessSignInData( + username: _username.trim(), + preferredFactor: factor, + ), + ), ); - _authBloc.add(AuthSignIn(signIn)); await nextBlocEvent(); _setIsBusy(false); } diff --git a/packages/authenticator/amplify_authenticator/lib/src/state/inherited_forms.dart b/packages/authenticator/amplify_authenticator/lib/src/state/inherited_forms.dart index fd31119f6af..2802a20bb16 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/state/inherited_forms.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/state/inherited_forms.dart @@ -21,6 +21,8 @@ class InheritedForms extends InheritedWidget { required this.continueSignInWithEmailMfaSetupForm, required this.confirmSignInWithTotpMfaCodeForm, required this.confirmSignInWithOtpCodeForm, + required this.continueSignInWithFirstFactorSelectionForm, + required this.passkeyPromptForm, required this.verifyUserForm, required this.confirmVerifyUserForm, required super.child, @@ -39,6 +41,9 @@ class InheritedForms extends InheritedWidget { final ContinueSignInWithEmailMfaSetupForm continueSignInWithEmailMfaSetupForm; final ConfirmSignInMFAForm confirmSignInWithTotpMfaCodeForm; final ConfirmSignInMFAForm confirmSignInWithOtpCodeForm; + final ContinueSignInWithFirstFactorSelectionForm + continueSignInWithFirstFactorSelectionForm; + final PasskeyPromptForm passkeyPromptForm; final ResetPasswordForm resetPasswordForm; final ConfirmResetPasswordForm confirmResetPasswordForm; final VerifyUserForm verifyUserForm; @@ -72,6 +77,10 @@ class InheritedForms extends InheritedWidget { return continueSignInWithEmailMfaSetupForm; case AuthenticatorStep.confirmSignInWithOtpCode: return confirmSignInWithOtpCodeForm; + case AuthenticatorStep.continueSignInWithFirstFactorSelection: + return continueSignInWithFirstFactorSelectionForm; + case AuthenticatorStep.passkeyPrompt: + return passkeyPromptForm; case AuthenticatorStep.resetPassword: return resetPasswordForm; case AuthenticatorStep.confirmResetPassword: @@ -123,7 +132,10 @@ class InheritedForms extends InheritedWidget { oldWidget.continueSignInWithEmailMfaSetupForm != continueSignInWithEmailMfaSetupForm || oldWidget.continueSignInWithMfaSetupSelectionForm != - continueSignInWithMfaSetupSelectionForm; + continueSignInWithMfaSetupSelectionForm || + oldWidget.continueSignInWithFirstFactorSelectionForm != + continueSignInWithFirstFactorSelectionForm || + oldWidget.passkeyPromptForm != passkeyPromptForm; } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/button.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/button.dart index 0718249e524..b986a791f7b 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/button.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/button.dart @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +// ignore_for_file: diagnostic_describe_all_properties import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_authenticator/src/constants/authenticator_constants.dart'; import 'package:amplify_authenticator/src/keys.dart'; @@ -9,6 +10,7 @@ import 'package:amplify_authenticator/src/state/inherited_auth_bloc.dart'; import 'package:amplify_authenticator/src/utils/list.dart'; import 'package:amplify_authenticator/src/widgets/component.dart'; import 'package:amplify_authenticator/src/widgets/progress.dart'; +import 'package:amplify_core/amplify_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -182,6 +184,68 @@ class SignInButton extends AuthenticatorElevatedButton { state.signIn(); } +/// A sign-in button that always uses the password flow, bypassing any +/// passwordless preference. Used in the "Other sign-in options" view. +class SignInWithPasswordButton extends AuthenticatorElevatedButton { + const SignInWithPasswordButton({super.key}) : super(); + + @override + ButtonResolverKey get labelKey => ButtonResolverKey.signIn; + + @override + void onPressed(BuildContext context, AuthenticatorState state) => + state.signInWithPassword(); +} + +/// Password sign-in button with "Sign in with password" label. +class SignInWithPasswordLabeledButton extends AuthenticatorElevatedButton { + const SignInWithPasswordLabeledButton({super.key}) : super(); + + @override + ButtonResolverKey get labelKey => ButtonResolverKey.signInWithPassword; + + @override + void onPressed(BuildContext context, AuthenticatorState state) => + state.signInWithPassword(); +} + +/// A sign-in button that uses the passkey (passwordless) flow. +class SignInWithPasskeyButton extends AuthenticatorElevatedButton { + const SignInWithPasskeyButton({super.key}) : super(); + + @override + ButtonResolverKey get labelKey => ButtonResolverKey.signInWithPasskey; + + @override + void onPressed(BuildContext context, AuthenticatorState state) => + state.signIn(); +} + +/// A sign-in button for a specific [AuthFactorType] (passwordless). +class FactorSignInButton extends AuthenticatorElevatedButton { + const FactorSignInButton({required this.factor, super.key}) : super(); + + final AuthFactorType factor; + + @override + ButtonResolverKey get labelKey { + switch (factor) { + case AuthFactorType.webAuthn: + return ButtonResolverKey.signInWithPasskey; + case AuthFactorType.emailOtp: + return ButtonResolverKey.signInWithEmail; + case AuthFactorType.smsOtp: + return ButtonResolverKey.signInWithSms; + default: + return ButtonResolverKey.signIn; + } + } + + @override + void onPressed(BuildContext context, AuthenticatorState state) => + state.signInWithFactor(factor); +} + /// {@category Prebuilt Widgets} /// {@template amplify_authenticator.confirm_sign_up_button} /// A prebuilt button for completing the sign up flow with a confirmation code. diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart index 25bbb022a38..79dc7862d79 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart @@ -1,11 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +// ignore_for_file: diagnostic_describe_all_properties, cascade_invocations + library; import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/blocs/auth/auth_bloc.dart'; +import 'package:amplify_authenticator/src/blocs/auth/auth_data.dart'; import 'package:amplify_authenticator/src/enums/enums.dart'; import 'package:amplify_authenticator/src/mixins/authenticator_username_field.dart'; +import 'package:amplify_authenticator/src/state/auth_state.dart'; +import 'package:amplify_authenticator/src/state/inherited_auth_bloc.dart'; import 'package:amplify_authenticator/src/state/inherited_authenticator_state.dart'; import 'package:amplify_authenticator/src/state/inherited_config.dart'; import 'package:amplify_authenticator/src/utils/list.dart'; @@ -400,9 +406,212 @@ class SignInForm extends AuthenticatorForm { AuthenticatorFormState createState() => _SignInFormState(); } +class _PrimaryView extends StatelessWidget { + const _PrimaryView({ + super.key, + required this.preferred, + required this.otherMethods, + required this.buttonResolver, + required this.onShowOther, + }); + + final AuthFactorType preferred; + final List otherMethods; + final ButtonResolver buttonResolver; + final VoidCallback onShowOther; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SignInFormField.username(), + const SizedBox(height: 16), + FactorSignInButton(factor: preferred), + if (otherMethods.isNotEmpty) ...[ + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: onShowOther, + child: Text( + buttonResolver.resolve( + context, + ButtonResolverKey.otherSignInOptions, + ), + ), + ), + ), + ], + ], + ); + } +} + +class _OtherOptionsView extends StatelessWidget { + const _OtherOptionsView({ + super.key, + required this.preferred, + required this.otherMethods, + required this.buttonResolver, + required this.stringResolver, + required this.onBack, + }); + + final AuthFactorType preferred; + final List otherMethods; + final ButtonResolver buttonResolver; + final AuthStringResolver stringResolver; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final username = InheritedAuthenticatorState.of(context).username; + final hasPassword = otherMethods.contains(AuthFactorType.password); + // Include the preferred method as a passwordless option on this screen + final passwordlessMethods = [ + if (preferred != AuthFactorType.password) preferred, + ...otherMethods.where((m) => m != AuthFactorType.password), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Read-only username + TextFormField( + initialValue: username, + readOnly: true, + enabled: false, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 16), + + // Password section + if (hasPassword) ...[ + SignInFormField.password(), + const SizedBox(height: 8), + const SignInWithPasswordLabeledButton(), + ], + + // "or" divider + if (hasPassword && passwordlessMethods.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('or', style: Theme.of(context).textTheme.bodySmall), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 8), + ], + + // Passwordless buttons + ...passwordlessMethods.map( + (factor) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FactorSignInButton(factor: factor), + ), + ), + + const SizedBox(height: 4), + Center( + child: TextButton( + onPressed: onBack, + child: Text( + stringResolver.buttons.backTo(context, AuthenticatorStep.signIn), + ), + ), + ), + ], + ); + } +} + class _SignInFormState extends AuthenticatorFormState { _SignInFormState() : super(); + bool _showOtherOptions = false; + + PasswordlessSettings? get _passwordlessSettings { + return InheritedAuthBloc.of(context, listen: false).passwordlessSettings; + } + + AuthFactorType? get _preferred { + // Explicit setting takes priority + final explicit = _passwordlessSettings?.preferredAuthMethod; + if (explicit != null) return explicit; + // Fall back to backend config + return InheritedConfig.of( + context, + ).amplifyOutputs?.auth?.passwordless?.preferredChallenge; + } + + List get _otherMethods { + final preferred = _preferred; + if (preferred == null) return []; + // Use explicit list if provided, otherwise derive from backend config + final settings = _passwordlessSettings; + if (settings?.availableAuthMethods != null) { + return settings!.otherAuthMethods; + } + final outputs = InheritedConfig.of( + context, + ).amplifyOutputs?.auth?.passwordless; + final all = [ + AuthFactorType.webAuthn, + if (outputs?.emailOtpEnabled ?? false) AuthFactorType.emailOtp, + if (outputs?.smsOtpEnabled ?? false) AuthFactorType.smsOtp, + AuthFactorType.password, + ]; + final hidden = settings?.hiddenAuthMethods ?? []; + return all.where((m) => m != preferred && !hidden.contains(m)).toList(); + } + + @override + Widget build(BuildContext context) { + final preferred = _preferred; + // No preferred method set — use standard password form + if (preferred == null || preferred == AuthFactorType.password) { + return super.build(context); + } + + final formKey = InheritedAuthenticatorState.of(context).formKey; + final buttonResolver = stringResolver.buttons; + final otherMethods = _otherMethods; + + return Form( + key: formKey, + child: AutofillGroup( + child: AnimatedSwitcher( + duration: Duration.zero, + child: _showOtherOptions + ? _OtherOptionsView( + key: const ValueKey('other'), + preferred: preferred, + otherMethods: otherMethods, + buttonResolver: buttonResolver, + stringResolver: stringResolver, + onBack: () => setState(() => _showOtherOptions = false), + ) + : _PrimaryView( + key: const ValueKey('primary'), + preferred: preferred, + otherMethods: otherMethods, + buttonResolver: buttonResolver, + onShowOther: () { + if (formKey.currentState?.validate() ?? false) { + setState(() => _showOtherOptions = true); + } + }, + ), + ), + ), + ); + } + @override List runtimeActions(BuildContext context) { if (!widget.includeDefaultSocialProviders) { @@ -696,6 +905,481 @@ class ConfirmVerifyUserForm extends AuthenticatorForm { AuthenticatorFormState(); } +/// {@category Prebuilt Widgets} +/// {@template amplify_authenticator.continue_sign_in_with_first_factor_selection_form} +/// A prebuilt form for selecting a first-factor authentication method. +/// +/// This form renders a challenge selection screen with: +/// - A read-only username display at the top +/// - An inline password field + submit button if PASSWORD is available +/// - A divider between password and passwordless methods +/// - Full-width buttons for each passwordless method (passkey, email OTP, SMS OTP) +/// - A back-to-sign-in link at the bottom +/// {@endtemplate} +class ContinueSignInWithFirstFactorSelectionForm extends AuthenticatorForm { + /// {@macro amplify_authenticator.continue_sign_in_with_first_factor_selection_form} + const ContinueSignInWithFirstFactorSelectionForm({super.key}) + : super._(fields: const [], actions: const []); + + @override + AuthenticatorFormState + createState() => _ContinueSignInWithFirstFactorSelectionFormState(); +} + +class _ContinueSignInWithFirstFactorSelectionFormState + extends AuthenticatorFormState { + bool _isPasskeySupported = false; + bool _isPasskeySupportChecked = false; + bool _isSubmitting = false; + final _passwordController = TextEditingController(); + + @override + void initState() { + super.initState(); + _checkPasskeySupport(); + } + + Future _checkPasskeySupport() async { + try { + final supported = await Amplify.Auth.isPasskeySupported(); + if (mounted) { + setState(() { + _isPasskeySupported = supported; + _isPasskeySupportChecked = true; + }); + } + } on Exception { + if (mounted) { + setState(() { + _isPasskeySupported = false; + _isPasskeySupportChecked = true; + }); + } + } + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Set get _availableFactors { + final bloc = InheritedAuthBloc.of(context, listen: false); + final currentState = bloc.currentState; + if (currentState is ContinueSignInWithFirstFactorSelection) { + return currentState.availableFactors; + } + return const {}; + } + + Set get _filteredFactors { + final factors = Set.from(_availableFactors); + + // Remove passkey if not supported on this platform + if (!_isPasskeySupported) { + factors.remove(AuthFactorType.webAuthn); + } + + // Apply hiddenAuthMethods from PasswordlessSettings if available + final authenticator = context + .findAncestorWidgetOfExactType(); + final hiddenMethods = + authenticator?.passwordlessSettings?.hiddenAuthMethods; + if (hiddenMethods != null) { + factors.removeAll(hiddenMethods); + } + + return factors; + } + + bool get _hasPassword => _filteredFactors.contains(AuthFactorType.password); + + List get _passwordlessMethods { + return _filteredFactors.where((f) => f != AuthFactorType.password).toList(); + } + + Future _submitPassword() async { + final password = _passwordController.text.trim(); + if (password.isEmpty) return; + + setState(() => _isSubmitting = true); + + final confirm = AuthConfirmSignInData(confirmationValue: password); + final bloc = InheritedAuthBloc.of(context, listen: false); + bloc.add(AuthConfirmSignIn(confirm)); + // ignore: invalid_use_of_visible_for_testing_member + await state.nextBlocEvent(); + + if (mounted) { + setState(() => _isSubmitting = false); + } + } + + Future _selectFactor(AuthFactorType factor) async { + setState(() => _isSubmitting = true); + + final confirm = AuthConfirmSignInData(confirmationValue: factor.value); + final bloc = InheritedAuthBloc.of(context, listen: false); + bloc.add(AuthConfirmSignIn(confirm)); + // ignore: invalid_use_of_visible_for_testing_member + await state.nextBlocEvent(); + + if (mounted) { + setState(() => _isSubmitting = false); + } + } + + ButtonResolverKey _buttonKeyForFactor(AuthFactorType factor) { + switch (factor) { + case AuthFactorType.webAuthn: + return ButtonResolverKey.signInWithPasskey; + case AuthFactorType.emailOtp: + return ButtonResolverKey.signInWithEmail; + case AuthFactorType.smsOtp: + return ButtonResolverKey.signInWithSms; + default: + return ButtonResolverKey.continueLabel; + } + } + + @override + Widget build(BuildContext context) { + if (!_isPasskeySupportChecked) { + return const Center(child: CircularProgressIndicator()); + } + + final buttonResolver = stringResolver.buttons; + final formKey = InheritedAuthenticatorState.of(context).formKey; + final passwordlessMethods = _passwordlessMethods; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Username display (read-only) + if (state.username.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + state.username, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + textAlign: TextAlign.center, + ), + ), + + // Password section + if (_hasPassword) ...[ + TextFormField( + controller: _passwordController, + obscureText: true, + enabled: !_isSubmitting, + decoration: InputDecoration( + labelText: buttonResolver.resolve( + context, + ButtonResolverKey.signInWithPassword, + ), + ), + onFieldSubmitted: (_) => _submitPassword(), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _isSubmitting + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + onPressed: _submitPassword, + child: Text( + buttonResolver.resolve( + context, + ButtonResolverKey.signInWithPassword, + ), + ), + ), + ), + ], + + // Divider between password and passwordless methods + if (_hasPassword && passwordlessMethods.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'or', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 16), + ], + + // Passwordless method buttons + ...passwordlessMethods.map( + (factor) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _isSubmitting ? null : () => _selectFactor(factor), + child: Text( + buttonResolver.resolve( + context, + _buttonKeyForFactor(factor), + ), + ), + ), + ), + ), + ), + + // Back to sign in + const SizedBox(height: 4), + const BackToSignInButton(), + ], + ), + ); + } +} + +/// {@category Prebuilt Widgets} +/// {@template amplify_authenticator.passkey_prompt_form} +/// A prebuilt form for the post-authentication passkey registration prompt. +/// +/// This form has three visual states: +/// - **Initial**: Heading, description, passkey icon, create button, skip link +/// - **Loading**: Same layout with disabled buttons and progress indicator +/// - **Success**: Success icon, credential list, set up another link, continue button +/// {@endtemplate} +class PasskeyPromptForm extends AuthenticatorForm { + /// {@macro amplify_authenticator.passkey_prompt_form} + const PasskeyPromptForm({super.key}) + : super._(fields: const [], actions: const []); + + @override + AuthenticatorFormState createState() => + _PasskeyPromptFormState(); +} + +class _PasskeyPromptFormState + extends AuthenticatorFormState { + PasskeyPromptState get _promptState { + final bloc = InheritedAuthBloc.of(context, listen: false); + final currentState = bloc.currentState; + if (currentState is PasskeyPromptState) { + return currentState; + } + return const PasskeyPromptState(); + } + + void _createPasskey() { + final bloc = InheritedAuthBloc.of(context, listen: false); + bloc.add(const AuthPasskeyRegister()); + } + + void _skipPasskey() { + final bloc = InheritedAuthBloc.of(context, listen: false); + bloc.add(const AuthPasskeySkip()); + } + + @override + Widget build(BuildContext context) { + final promptState = _promptState; + final titleResolver = stringResolver.titles; + final buttonResolver = stringResolver.buttons; + final messageResolver = stringResolver.messages; + final theme = Theme.of(context); + final formKey = InheritedAuthenticatorState.of(context).formKey; + + if (promptState.isSuccess) { + return _buildSuccessView( + context, + formKey: formKey, + promptState: promptState, + titleResolver: titleResolver, + buttonResolver: buttonResolver, + theme: theme, + ); + } + + return _buildPromptView( + context, + formKey: formKey, + promptState: promptState, + titleResolver: titleResolver, + buttonResolver: buttonResolver, + messageResolver: messageResolver, + theme: theme, + ); + } + + Widget _buildPromptView( + BuildContext context, { + required Key formKey, + required PasskeyPromptState promptState, + required TitleResolver titleResolver, + required ButtonResolver buttonResolver, + required MessageResolver messageResolver, + required ThemeData theme, + }) { + final isRegistering = promptState.isRegistering; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Passkey icon + Icon(Icons.fingerprint, size: 64, color: theme.colorScheme.primary), + const SizedBox(height: 16), + + // Description text + Text( + messageResolver.passkeyPromptDescription(context), + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + + // Error message (if any) + if (promptState.errorMessage != null) ...[ + Text( + promptState.errorMessage!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ], + + // Create a passkey button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isRegistering ? null : _createPasskey, + child: isRegistering + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + buttonResolver.resolve( + context, + ButtonResolverKey.createPasskey, + ), + ), + ), + ), + const SizedBox(height: 12), + + // Continue without a passkey link + Center( + child: TextButton( + onPressed: isRegistering ? null : _skipPasskey, + child: Text( + buttonResolver.resolve( + context, + ButtonResolverKey.continueWithoutPasskey, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSuccessView( + BuildContext context, { + required Key formKey, + required PasskeyPromptState promptState, + required TitleResolver titleResolver, + required ButtonResolver buttonResolver, + required ThemeData theme, + }) { + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Success icon + const Icon(Icons.check_circle, color: Colors.green, size: 64), + const SizedBox(height: 16), + + // Success title + Text( + titleResolver.passkeyCreatedSuccess(context), + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Registered credentials list + if (promptState.registeredCredentials.isNotEmpty) ...[ + ...promptState.registeredCredentials.map( + (credential) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: const Icon(Icons.key), + title: Text( + credential.friendlyName ?? + credential.credentialId.substring( + 0, + credential.credentialId.length.clamp(0, 8), + ), + ), + subtitle: Text( + credential.createdAt.toLocal().toString().split('.').first, + ), + dense: true, + ), + ), + ), + const SizedBox(height: 8), + ], + + // Set up another passkey link + Center( + child: TextButton( + onPressed: _createPasskey, + child: Text( + buttonResolver.resolve( + context, + ButtonResolverKey.setupAnotherPasskey, + ), + ), + ), + ), + const SizedBox(height: 12), + + // Continue button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _skipPasskey, + child: Text( + buttonResolver.resolve( + context, + ButtonResolverKey.continueLabel, + ), + ), + ), + ), + ], + ), + ); + } +} + String _duplicateFieldLog(String alias, String field) { return 'SignUpForm contains `SignUpFormField.$field`, but Amplify Auth is ' 'configured to use $alias as a username. This will result in duplicate ' diff --git a/packages/authenticator/amplify_authenticator/test/factor_selection_screen_test.dart b/packages/authenticator/amplify_authenticator/test/factor_selection_screen_test.dart new file mode 100644 index 00000000000..93d766f18ea --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/factor_selection_screen_test.dart @@ -0,0 +1,99 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/state/auth_state.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// UI-01: Factor selection screen state and configuration tests. +void main() { + group('ContinueSignInWithFirstFactorSelection', () { + test('default constructor creates state with empty factors', () { + const state = ContinueSignInWithFirstFactorSelection(); + expect(state.availableFactors, isEmpty); + }); + + test('constructor accepts a set of available factors', () { + const state = ContinueSignInWithFirstFactorSelection( + availableFactors: { + AuthFactorType.password, + AuthFactorType.webAuthn, + AuthFactorType.emailOtp, + }, + ); + expect(state.availableFactors, hasLength(3)); + expect(state.availableFactors, contains(AuthFactorType.password)); + expect(state.availableFactors, contains(AuthFactorType.webAuthn)); + expect(state.availableFactors, contains(AuthFactorType.emailOtp)); + }); + + test('step is continueSignInWithFirstFactorSelection', () { + const state = ContinueSignInWithFirstFactorSelection(); + expect( + state.step, + AuthenticatorStep.continueSignInWithFirstFactorSelection, + ); + }); + + test('equality holds for states with same factors', () { + const state1 = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn}, + ); + const state2 = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn}, + ); + expect(state1, equals(state2)); + }); + + test('inequality for states with different factors', () { + const state1 = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn}, + ); + const state2 = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.password}, + ); + expect(state1, isNot(equals(state2))); + }); + + test('runtimeTypeName returns correct value', () { + const state = ContinueSignInWithFirstFactorSelection(); + expect(state.runtimeTypeName, 'ContinueSignInWithFirstFactorSelection'); + }); + + test('is a subclass of UnauthenticatedState', () { + const state = ContinueSignInWithFirstFactorSelection(); + expect(state, isA()); + }); + + test('webAuthn factor type can represent passkey selection', () { + const state = ContinueSignInWithFirstFactorSelection( + availableFactors: { + AuthFactorType.password, + AuthFactorType.webAuthn, + AuthFactorType.smsOtp, + }, + ); + final hasPasskey = state.availableFactors.contains( + AuthFactorType.webAuthn, + ); + expect(hasPasskey, isTrue); + }); + }); + + group('AuthenticatorStep enum', () { + test('contains continueSignInWithFirstFactorSelection value', () { + expect( + AuthenticatorStep.values, + contains(AuthenticatorStep.continueSignInWithFirstFactorSelection), + ); + }); + + test('contains passkeyPrompt value', () { + expect( + AuthenticatorStep.values, + contains(AuthenticatorStep.passkeyPrompt), + ); + }); + }); +} diff --git a/packages/authenticator/amplify_authenticator/test/passkey_error_messages_test.dart b/packages/authenticator/amplify_authenticator/test/passkey_error_messages_test.dart new file mode 100644 index 00000000000..2364a0ce2bc --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/passkey_error_messages_test.dart @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/l10n/button_resolver.dart'; +import 'package:amplify_authenticator/src/state/auth_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// UI-04: Passkey error display and localization resolver key tests. +void main() { + group('PasskeyPromptState error handling', () { + test('error state carries error message', () { + const state = PasskeyPromptState( + errorMessage: 'Platform error: WebAuthn not supported', + ); + expect(state.errorMessage, isNotNull); + expect(state.errorMessage, contains('WebAuthn')); + }); + + test('error state with no error message returns null', () { + const state = PasskeyPromptState(); + expect(state.errorMessage, isNull); + }); + + test('error state is distinct from registering state', () { + const errorState = PasskeyPromptState( + errorMessage: 'Registration failed', + ); + const registeringState = PasskeyPromptState(isRegistering: true); + expect(errorState, isNot(equals(registeringState))); + }); + + test('error state is distinct from success state', () { + const errorState = PasskeyPromptState( + errorMessage: 'Registration failed', + ); + const successState = PasskeyPromptState(isSuccess: true); + expect(errorState, isNot(equals(successState))); + }); + }); + + group('ButtonResolverKeyType passkey entries', () { + test('contains signInWithPasskey key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.signInWithPasskey), + ); + }); + + test('contains signInWithPassword key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.signInWithPassword), + ); + }); + + test('contains signInWithEmail key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.signInWithEmail), + ); + }); + + test('contains signInWithSms key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.signInWithSms), + ); + }); + + test('contains createPasskey key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.createPasskey), + ); + }); + + test('contains continueWithoutPasskey key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.continueWithoutPasskey), + ); + }); + + test('contains setupAnotherPasskey key type', () { + expect( + ButtonResolverKeyType.values, + contains(ButtonResolverKeyType.setupAnotherPasskey), + ); + }); + }); + + group('ButtonResolverKey static constants', () { + test('signInWithPasskey has correct type', () { + expect( + ButtonResolverKey.signInWithPasskey.type, + ButtonResolverKeyType.signInWithPasskey, + ); + }); + + test('createPasskey has correct type', () { + expect( + ButtonResolverKey.createPasskey.type, + ButtonResolverKeyType.createPasskey, + ); + }); + + test('continueWithoutPasskey has correct type', () { + expect( + ButtonResolverKey.continueWithoutPasskey.type, + ButtonResolverKeyType.continueWithoutPasskey, + ); + }); + + test('setupAnotherPasskey has correct type', () { + expect( + ButtonResolverKey.setupAnotherPasskey.type, + ButtonResolverKeyType.setupAnotherPasskey, + ); + }); + }); + + group('MessageResolverKeyType passkey entries', () { + test('contains passkeyRegistrationFailed', () { + expect( + MessageResolverKeyType.values, + contains(MessageResolverKeyType.passkeyRegistrationFailed), + ); + }); + + test('contains passkeyCeremonyFailed', () { + expect( + MessageResolverKeyType.values, + contains(MessageResolverKeyType.passkeyCeremonyFailed), + ); + }); + + test('contains passkeyPromptDescription', () { + expect( + MessageResolverKeyType.values, + contains(MessageResolverKeyType.passkeyPromptDescription), + ); + }); + }); + + group('MessageResolverKey static constants', () { + test('passkeyRegistrationFailed has correct type', () { + expect( + MessageResolverKey.passkeyRegistrationFailed.type, + MessageResolverKeyType.passkeyRegistrationFailed, + ); + }); + + test('passkeyCeremonyFailed has correct type', () { + expect( + MessageResolverKey.passkeyCeremonyFailed.type, + MessageResolverKeyType.passkeyCeremonyFailed, + ); + }); + + test('passkeyPromptDescription has correct type', () { + expect( + MessageResolverKey.passkeyPromptDescription.type, + MessageResolverKeyType.passkeyPromptDescription, + ); + }); + }); + + group('TitleResolver covers passkey steps', () { + test('TitleResolver is instantiable', () { + const resolver = TitleResolver(); + expect(resolver, isA()); + }); + }); + + group('AuthenticatorStep title coverage', () { + test( + 'continueSignInWithFirstFactorSelection is a valid step for title resolution', + () { + // Verify the enum value exists and is usable + const step = AuthenticatorStep.continueSignInWithFirstFactorSelection; + expect(step.name, 'continueSignInWithFirstFactorSelection'); + }, + ); + + test('passkeyPrompt is a valid step for title resolution', () { + const step = AuthenticatorStep.passkeyPrompt; + expect(step.name, 'passkeyPrompt'); + }); + }); +} diff --git a/packages/authenticator/amplify_authenticator/test/passkey_registration_prompt_test.dart b/packages/authenticator/amplify_authenticator/test/passkey_registration_prompt_test.dart new file mode 100644 index 00000000000..3d4a002caff --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/passkey_registration_prompt_test.dart @@ -0,0 +1,152 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/state/auth_state.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// UI-03: Passkey registration prompt state and model tests. +void main() { + group('PasskeyPromptState', () { + test('default constructor has expected defaults', () { + const state = PasskeyPromptState(); + expect(state.isRegistering, isFalse); + expect(state.isSuccess, isFalse); + expect(state.errorMessage, isNull); + expect(state.registeredCredentials, isEmpty); + expect(state.step, AuthenticatorStep.passkeyPrompt); + }); + + test('isRegistering flag indicates active registration', () { + const state = PasskeyPromptState(isRegistering: true); + expect(state.isRegistering, isTrue); + expect(state.isSuccess, isFalse); + }); + + test('isSuccess flag indicates completed registration', () { + const state = PasskeyPromptState(isSuccess: true); + expect(state.isSuccess, isTrue); + expect(state.isRegistering, isFalse); + }); + + test('errorMessage is accessible when set', () { + const state = PasskeyPromptState( + errorMessage: 'Passkey registration failed', + ); + expect(state.errorMessage, 'Passkey registration failed'); + }); + + test('equality holds for states with same properties', () { + const state1 = PasskeyPromptState(isRegistering: true); + const state2 = PasskeyPromptState(isRegistering: true); + expect(state1, equals(state2)); + }); + + test('inequality for states with different properties', () { + const state1 = PasskeyPromptState(isRegistering: true); + const state2 = PasskeyPromptState(isSuccess: true); + expect(state1, isNot(equals(state2))); + }); + + test('props include all fields', () { + const state = PasskeyPromptState( + isRegistering: true, + errorMessage: 'error', + ); + // props: [step, isRegistering, isSuccess, errorMessage, registeredCredentials] + expect(state.props, hasLength(5)); + expect(state.props[0], AuthenticatorStep.passkeyPrompt); + expect(state.props[1], true); // isRegistering + expect(state.props[2], false); // isSuccess + expect(state.props[3], 'error'); // errorMessage + expect(state.props[4], const []); // registeredCredentials + }); + + test('runtimeTypeName returns correct value', () { + const state = PasskeyPromptState(); + expect(state.runtimeTypeName, 'PasskeyPromptState'); + }); + }); + + group('PasskeyRegistrationPrompts', () { + test( + 'default constructor enables prompts after both sign-in and sign-up', + () { + const prompts = PasskeyRegistrationPrompts(); + expect(prompts.isEnabledAfterSignIn, isTrue); + expect(prompts.isEnabledAfterSignUp, isTrue); + }, + ); + + test('enabled constructor enables all prompts', () { + const prompts = PasskeyRegistrationPrompts.enabled(); + expect(prompts.afterSignIn, PasskeyPromptBehavior.always); + expect(prompts.afterSignUp, PasskeyPromptBehavior.always); + expect(prompts.isEnabledAfterSignIn, isTrue); + expect(prompts.isEnabledAfterSignUp, isTrue); + }); + + test('disabled constructor disables all prompts', () { + const prompts = PasskeyRegistrationPrompts.disabled(); + expect(prompts.afterSignIn, PasskeyPromptBehavior.never); + expect(prompts.afterSignUp, PasskeyPromptBehavior.never); + expect(prompts.isEnabledAfterSignIn, isFalse); + expect(prompts.isEnabledAfterSignUp, isFalse); + }); + + test('custom configuration with mixed behaviors', () { + const prompts = PasskeyRegistrationPrompts( + afterSignIn: PasskeyPromptBehavior.always, + afterSignUp: PasskeyPromptBehavior.never, + ); + expect(prompts.isEnabledAfterSignIn, isTrue); + expect(prompts.isEnabledAfterSignUp, isFalse); + }); + }); + + group('PasswordlessSettings', () { + test('default constructor has all null fields', () { + const settings = PasswordlessSettings(); + expect(settings.hiddenAuthMethods, isNull); + expect(settings.preferredAuthMethod, isNull); + expect(settings.passkeyRegistrationPrompts, isNull); + }); + + test('accepts passkey registration prompts configuration', () { + const settings = PasswordlessSettings( + passkeyRegistrationPrompts: PasskeyRegistrationPrompts.enabled(), + ); + expect(settings.passkeyRegistrationPrompts, isNotNull); + expect(settings.passkeyRegistrationPrompts!.isEnabledAfterSignIn, isTrue); + }); + + test('accepts all configuration options together', () { + const settings = PasswordlessSettings( + hiddenAuthMethods: [AuthFactorType.smsOtp], + preferredAuthMethod: AuthFactorType.webAuthn, + passkeyRegistrationPrompts: PasskeyRegistrationPrompts.disabled(), + ); + expect(settings.hiddenAuthMethods, hasLength(1)); + expect(settings.preferredAuthMethod, AuthFactorType.webAuthn); + expect( + settings.passkeyRegistrationPrompts!.isEnabledAfterSignIn, + isFalse, + ); + }); + }); + + group('PasskeyPromptBehavior', () { + test('has two values: always and never', () { + expect(PasskeyPromptBehavior.values, hasLength(2)); + expect( + PasskeyPromptBehavior.values, + contains(PasskeyPromptBehavior.always), + ); + expect( + PasskeyPromptBehavior.values, + contains(PasskeyPromptBehavior.never), + ); + }); + }); +} diff --git a/packages/authenticator/amplify_authenticator/test/passkey_signin_flow_test.dart b/packages/authenticator/amplify_authenticator/test/passkey_signin_flow_test.dart new file mode 100644 index 00000000000..46f056424b3 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/passkey_signin_flow_test.dart @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/state/auth_state.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// UI-02: Passkey sign-in ceremony flow state transition tests. +void main() { + group('Passkey sign-in flow state transitions', () { + test( + 'ContinueSignInWithFirstFactorSelection includes webAuthn as selectable factor', + () { + const state = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.password, AuthFactorType.webAuthn}, + ); + expect(state.availableFactors, contains(AuthFactorType.webAuthn)); + expect( + state.step, + AuthenticatorStep.continueSignInWithFirstFactorSelection, + ); + }, + ); + + test('AuthFactorType.webAuthn has correct JSON value', () { + expect(AuthFactorType.webAuthn.value, 'WEB_AUTHN'); + }); + + test('all five AuthFactorType values are available', () { + expect(AuthFactorType.values, hasLength(5)); + expect(AuthFactorType.values, contains(AuthFactorType.password)); + expect(AuthFactorType.values, contains(AuthFactorType.passwordSrp)); + expect(AuthFactorType.values, contains(AuthFactorType.emailOtp)); + expect(AuthFactorType.values, contains(AuthFactorType.smsOtp)); + expect(AuthFactorType.values, contains(AuthFactorType.webAuthn)); + }); + + test( + 'factor selection state with only webAuthn represents passkey-only flow', + () { + const state = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn}, + ); + expect(state.availableFactors, hasLength(1)); + expect(state.availableFactors.first, AuthFactorType.webAuthn); + }, + ); + + test('factor selection state props include step and factors', () { + const state = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn, AuthFactorType.password}, + ); + expect(state.props, hasLength(2)); + expect( + state.props[0], + AuthenticatorStep.continueSignInWithFirstFactorSelection, + ); + expect(state.props[1], isA>()); + }); + + test( + 'PasswordlessSettings can specify preferred auth method as webAuthn', + () { + const settings = PasswordlessSettings( + preferredAuthMethod: AuthFactorType.webAuthn, + ); + expect(settings.preferredAuthMethod, AuthFactorType.webAuthn); + }, + ); + + test('PasswordlessSettings can hide specific auth methods', () { + const settings = PasswordlessSettings( + hiddenAuthMethods: [AuthFactorType.password, AuthFactorType.smsOtp], + ); + expect(settings.hiddenAuthMethods, hasLength(2)); + expect(settings.hiddenAuthMethods, contains(AuthFactorType.password)); + expect(settings.hiddenAuthMethods, contains(AuthFactorType.smsOtp)); + }); + + test('factor selection transitions from signIn step conceptually', () { + // Verify that these are distinct steps in the auth flow + const signInState = UnauthenticatedState.signIn; + const factorSelectionState = ContinueSignInWithFirstFactorSelection( + availableFactors: {AuthFactorType.webAuthn}, + ); + expect(signInState.step, isNot(equals(factorSelectionState.step))); + }); + }); +} diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_desktopGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_desktopGeometry.png new file mode 100644 index 00000000000..26bdaebf9b4 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_desktopGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_mobileGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_mobileGeometry.png new file mode 100644 index 00000000000..02d0dbd9415 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_darkMode_mobileGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_desktopGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_desktopGeometry.png new file mode 100644 index 00000000000..8d7435d67c9 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_desktopGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_mobileGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_mobileGeometry.png new file mode 100644 index 00000000000..dde23dde669 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_continueSignInWithFirstFactorSelectionStep_defaultMaterialTheme_lightMode_mobileGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_desktopGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_desktopGeometry.png new file mode 100644 index 00000000000..7a19deb83c8 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_desktopGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_mobileGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_mobileGeometry.png new file mode 100644 index 00000000000..047f8148b2e Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_darkMode_mobileGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_desktopGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_desktopGeometry.png new file mode 100644 index 00000000000..bfecc509a54 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_desktopGeometry.png differ diff --git a/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_mobileGeometry.png b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_mobileGeometry.png new file mode 100644 index 00000000000..303b7fa8144 Binary files /dev/null and b/packages/authenticator/amplify_authenticator/test/ui/goldens/theme_emailConfig_passkeyPromptStep_defaultMaterialTheme_lightMode_mobileGeometry.png differ diff --git a/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig index 72fc3a33af7..a96a0b4e803 100644 --- a/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +++ b/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -1,6 +1,6 @@ // This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=/Users/tyllark/development/flutter -FLUTTER_APPLICATION_PATH=/Users/tyllark/Documents/GitHub/amplify-flutter/packages/notifications/push/amplify_push_notifications/example +FLUTTER_ROOT=/home/ec2-user/.flutter-sdk +FLUTTER_APPLICATION_PATH=/home/ec2-user/Work/AmplifyDev/amplify-flutter/packages/notifications/push/amplify_push_notifications/example COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_BUILD_DIR=build FLUTTER_BUILD_NAME=0.1.0 diff --git a/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/flutter_export_environment.sh b/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/flutter_export_environment.sh index 598841f2049..c2e3bd3eca4 100755 --- a/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/flutter_export_environment.sh +++ b/packages/notifications/push/amplify_push_notifications/example/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -1,7 +1,7 @@ #!/bin/sh # This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=/Users/tyllark/development/flutter" -export "FLUTTER_APPLICATION_PATH=/Users/tyllark/Documents/GitHub/amplify-flutter/packages/notifications/push/amplify_push_notifications/example" +export "FLUTTER_ROOT=/home/ec2-user/.flutter-sdk" +export "FLUTTER_APPLICATION_PATH=/home/ec2-user/Work/AmplifyDev/amplify-flutter/packages/notifications/push/amplify_push_notifications/example" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_NAME=0.1.0" diff --git a/packages/test/amplify_integration_test/lib/src/stubs/amplify_auth_cognito_stub.dart b/packages/test/amplify_integration_test/lib/src/stubs/amplify_auth_cognito_stub.dart index 3e0dcc74d6d..0b2b0b27c51 100644 --- a/packages/test/amplify_integration_test/lib/src/stubs/amplify_auth_cognito_stub.dart +++ b/packages/test/amplify_integration_test/lib/src/stubs/amplify_auth_cognito_stub.dart @@ -375,6 +375,11 @@ class AmplifyAuthCognitoStub extends AuthPluginInterface Future deleteUser() async { throw UnimplementedError('deleteUser is not implemented.'); } + + @override + Future isPasskeySupported() async { + return false; + } } class MockCognitoUser {