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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cursor/rules/project.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Project-wide rules for Flutter Passkeys monorepo
globs:
alwaysApply: true
---

Read and follow all instructions in @CLAUDE.md — it is the single source of truth for project rules.
4 changes: 2 additions & 2 deletions .github/workflows/deploy-corbado-auth-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5

- name: Set up Flutter
uses: subosito/flutter-action@v2
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy-passkeys-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5

- name: Set up Flutter
uses: subosito/flutter-action@v2
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Flutter Passkeys

## Structure

Melos monorepo. `melos bootstrap` to set up, then `melos run <script>` (see `melos.yaml`).

| Package | Purpose |
|---|---|
| `passkeys` | Main Flutter plugin (Dart layer, exception mapping) |
| `passkeys_android` | Android (Credential Manager via Pigeon) |
| `passkeys_darwin` | iOS + macOS (Swift) — note: NOT `passkeys_ios` |
| `passkeys_web` | Web (JS interop, has TypeScript build step) |
| `passkeys_windows` | Windows |
| `passkeys_platform_interface` | Shared types + platform interface |
| `corbado_auth` | Higher-level auth package built on `passkeys` |
| `corbado_auth_firebase` | Firebase integration — **currently broken, no ETA** |
| `corbado_api_client` | Generated Corbado API client |

## Architecture

- Native code catches platform exceptions → `FlutterError` with string error codes (e.g. `cancelled`, `android-no-create-option`, `domain-not-associated`)
- `authenticator.dart` maps these codes → typed Dart exceptions (`PasskeyAuthCancelledException`, `NoCreateOptionException`, etc.)
- Android uses Pigeon for Flutter↔native communication

## Key workflows

- **Native code changes**: regenerate Pigeon — `cd packages/passkeys/passkeys_android && dart run pigeon --input pigeons/messages.dart`
- **Web JS changes**: `melos run build-passkeys-web-javascript` (builds TS, copies bundle to example)
- **Before committing**: `melos run format:check && melos run analyze`
- **PR titles**: Conventional Commits, e.g. `fix(passkeys_android): fixed a bug!`

## Release order

`passkeys_platform_interface` → platform packages → `passkeys` → `corbado_auth`. Only bump packages with actual changes. See CONTRIBUTING.md §7 for full process.
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
package com.corbado.passkeys.pub

import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.net.toUri
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example.passkeys/settings"
).setMethodCallHandler { call, result ->
if (call.method == "openCredentialProviderSettings") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val intent = Intent(Settings.ACTION_CREDENTIAL_PROVIDER).apply {
data = "package:${packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
result.success(true)
} else {
// Fallback: open general settings on older Android
startActivity(Intent(Settings.ACTION_SETTINGS))
result.success(false)
}
} else {
result.notImplemented()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>
2 changes: 1 addition & 1 deletion packages/passkeys/passkeys/example/ios/Podfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '12.0'
platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import UIKit
import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
21 changes: 21 additions & 0 deletions packages/passkeys/passkeys/example/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
Expand Down
4 changes: 3 additions & 1 deletion packages/passkeys/passkeys/example/lib/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ class Configuration {
this.timeout,
this.excludeCredentials,
this.allowCredentials,
this.preferImmediatelyAvailableCredentials});
this.preferImmediatelyAvailableCredentials,
this.authenticatorAttachment});

final String name;
final int? timeout;
final bool? excludeCredentials;
final bool? allowCredentials;
final bool? preferImmediatelyAvailableCredentials;
final String? authenticatorAttachment;
}

List<Configuration> SIGNUP_ANDROID_CONFIGURATIONS = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class LocalRelyingPartyServer {
'requireResidentKey': false,
'residentKey': 'required',
'userVerification': 'preferred',
if (configuration?.authenticatorAttachment != null)
'authenticatorAttachment': configuration!.authenticatorAttachment,
},
'timeout': configuration?.timeout ?? 60000,
};
Expand Down
82 changes: 80 additions & 2 deletions packages/passkeys/passkeys/example/lib/pages/sign_up_page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
Expand All @@ -13,14 +14,47 @@ import 'package:passkeys_example/providers.dart';
import 'package:passkeys_example/router.dart';
import 'package:passkeys_example/widgets/select_test_configuration.dart';

void _showNoCreateOptionDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Passkey cannot be created'),
content: const Text(
'No functional password manager is available on this device.\n\n'
'Possible reasons:\n'
'\u2022 No password manager selected in settings\n'
'\u2022 Password manager is disabled\n\n'
'Go to Settings \u2192 Passwords & accounts, disable the '
'preferred service by selecting "None", then re-enable it.',
),
actions: [
if (!kIsWeb && Platform.isAndroid)
TextButton(
onPressed: () {
Navigator.of(context).pop();
const MethodChannel('com.example.passkeys/settings')
.invokeMethod('openCredentialProviderSettings');
},
child: const Text('Open Settings'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}

class SignUpPage extends HookConsumerWidget {
const SignUpPage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = TextEditingController();
final emailController = useTextEditingController();
final error = useState<String?>(null);
final authService = ref.watch(authServiceProvider);
final authenticatorAttachment = useState<String?>(null);

bool isTestMode = const bool.fromEnvironment('TEST_MODE');

Expand Down Expand Up @@ -104,11 +138,22 @@ class SignUpPage extends HookConsumerWidget {
key: const Key('sign-up-button'),
onPressed: () async {
final email = emailController.value.text;
final attachment = authenticatorAttachment.value;
authService.setSignupConfiguration(
attachment != null
? Configuration(
name: 'custom',
authenticatorAttachment: attachment,
)
: null,
);
try {
await authService.signupWithPasskey(email: email);
context.go(Routes.profile);
} catch (e) {
if (e is AuthenticatorException) {
if (e is NoCreateOptionException) {
_showNoCreateOptionDialog(context);
} else if (e is AuthenticatorException) {
error.value = getFriendlyErrorMessage(e);
} else {
error.value = e.toString();
Expand All @@ -133,6 +178,39 @@ class SignUpPage extends HookConsumerWidget {
child: const Text('I already have an account'),
),
),
if (!kIsWeb) ...[
const SizedBox(height: 16),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Authenticator Attachment',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'none', label: Text('None')),
ButtonSegment(value: 'platform', label: Text('Platform')),
ButtonSegment(
value: 'cross-platform', label: Text('Cross-plat.')),
],
selected: {authenticatorAttachment.value ?? 'none'},
onSelectionChanged: (selected) {
final v = selected.first;
authenticatorAttachment.value = v == 'none' ? null : v;
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
textStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.labelSmall,
),
),
),
],
if (isTestMode)
SelectTestConfiguration(
configurations: Platform.isIOS
Expand Down
2 changes: 2 additions & 0 deletions packages/passkeys/passkeys/lib/authenticator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class PasskeyAuthenticator {
throw DeviceNotSupportedException();
case 'android-passkey-unsupported':
throw PasskeyUnsupportedException(e.message);
case 'android-no-create-option':
throw NoCreateOptionException(e.message);
case 'android-timeout':
throw TimeoutException(e.message);
case 'ios-security-key-timeout':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ public void onError(CreateCredentialException e) {
platformException = new Messages.FlutterError("cancelled", e.getMessage(), "");
} else if (e instanceof CreateCredentialCancellationException) {
platformException = new Messages.FlutterError("cancelled", e.getMessage(), "");
} else if (e instanceof CreatePublicKeyCredentialDomException) {
// TODO: Refactor CreatePublicKeyCredentialDomException handling to use
// ((CreatePublicKeyCredentialDomException) e).getDomError().getType()
// instead of matching on e.getMessage() strings, which are fragile and
// can change across Google Play Services versions.
// See: https://www.corbado.com/blog/google-play-services-passkey-error-codes
} else if (e instanceof CreatePublicKeyCredentialDomException) {
if (Objects.equals(e.getMessage(), "User is unable to create passkeys.")) {
platformException = new Messages.FlutterError("android-missing-google-sign-in",
e.getMessage(), MISSING_GOOGLE_SIGN_IN_ERROR);
Expand Down