A reference implementation demonstrating how to integrate Keycast authentication into a Flutter app. This project contains both a reusable library (keycast_flutter) and a working demo app.
Purpose: Working code as documentation for integrating Keycast into divine-mobile.
After authentication, KeycastSession provides two ways to sign Nostr events:
Direct HTTPS calls to Keycast's RPC API. This demo uses RPC for better latency and scalability.
final rpc = KeycastRpc.fromSession(config, session);
final signedEvent = await rpc.signEvent(event);The session also provides a bunkerUrl for NIP-46 remote signing over Nostr relays. Use this if you already have a NIP-46 client implementation (like NDK or a custom NostrRemoteSigner).
final bunkerUrl = session.bunkerUrl;
// bunker://<pubkey>?relay=wss://...&secret=...
// Use with your NIP-46 client:
final signer = NostrRemoteSigner.fromBunkerUrl(bunkerUrl);Both modes support the same operations: sign_event, get_public_key, nip44_encrypt, nip44_decrypt, nip04_encrypt, nip04_decrypt.
# Run on iOS simulator (recommended - OAuth works correctly)
flutter run -d "iPhone 15 Pro"
# Run on macOS (OAuth has known issues - see Troubleshooting)
flutter run -d macoskeycast_flutter_demo/
├── packages/
│ ├── keycast_flutter/ # The library - copy this to divine-mobile
│ └── nostr_sdk/ # Minimal nostr_sdk (vendored from divine-mobile)
├── lib/ # Demo app showing integration patterns
│ ├── main.dart # Deep link handling setup
│ ├── providers/ # Riverpod state management
│ └── screens/ # 3-step demo UI
└── ios/Runner/
└── Runner.entitlements # Universal Links config
Copy packages/keycast_flutter/ into divine-mobile/mobile/packages/.
Update divine-mobile/mobile/pubspec.yaml:
dependencies:
keycast_flutter:
path: packages/keycast_flutterCreate a provider for OAuth configuration:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:keycast_flutter/keycast_flutter.dart';
final oauthConfigProvider = Provider<OAuthConfig>((ref) {
return const OAuthConfig(
serverUrl: 'https://login.divine.video',
clientId: 'divine-mobile',
redirectUri: 'https://login.divine.video/app/callback',
);
});
final oauthClientProvider = Provider<KeycastOAuth>((ref) {
final config = ref.watch(oauthConfigProvider);
return KeycastOAuth(config: config);
});In your app's main widget, set up deep link handling:
import 'package:app_links/app_links.dart';
import 'package:keycast_flutter/keycast_flutter.dart';
class MyApp extends ConsumerStatefulWidget {
// ...
}
class _MyAppState extends ConsumerState<MyApp> {
final _appLinks = AppLinks();
@override
void initState() {
super.initState();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
// Handle app launch from Universal Link
final initialLink = await _appLinks.getInitialLink();
if (initialLink != null) {
_handleOAuthCallback(initialLink);
}
// Handle Universal Links while app is running
_appLinks.uriLinkStream.listen(_handleOAuthCallback);
}
Future<void> _handleOAuthCallback(Uri uri) async {
// Only handle our OAuth callback URL
if (uri.scheme != 'https' ||
uri.host != 'login.divine.video' ||
!uri.path.startsWith('/app/callback')) {
return;
}
final oauth = ref.read(oauthClientProvider);
final result = oauth.parseCallback(uri.toString());
if (result is CallbackSuccess) {
final verifier = ref.read(pendingVerifierProvider);
if (verifier == null) return;
try {
final tokenResponse = await oauth.exchangeCode(
code: result.code,
verifier: verifier,
);
final session = KeycastSession.fromTokenResponse(tokenResponse);
await session.save();
// Update your app state here
} catch (e) {
// Handle error
}
}
}
}Add to ios/Runner/Runner.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:login.divine.video</string>
</array>
</dict>
</plist>Add CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; to your Xcode project's build settings (Debug, Release, and Profile configurations).
Warning: macOS HTTPS callbacks with ASWebAuthenticationSession do not work reliably due to Apple platform differences. The completion handler often doesn't fire on macOS, even though the same API works on iOS. See Troubleshooting for details.
macOS requires Associated Domains for HTTPS callbacks. Add to both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:login.divine.video</string>
<string>webcredentials:login.divine.video</string>
</array>Note: macOS Universal Links require macOS 14.4+ and the same AASA file configuration as iOS. The app must be signed with an Apple Developer certificate that matches the AASA file's app IDs.
Add to android/app/src/main/AndroidManifest.xml inside <activity>:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="login.divine.video" android:pathPrefix="/app/callback"/>
</intent-filter>// User wants a NEW Nostr identity created by Keycast
void connectWithKeycast() async {
final oauth = ref.read(oauthClientProvider);
final (url, verifier) = oauth.getAuthorizationUrl(
scope: 'policy:social',
defaultRegister: true,
);
// Store verifier for token exchange later
ref.read(pendingVerifierProvider.notifier).set(verifier);
// Open OAuth page in browser
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}// User wants to use their EXISTING Nostr identity
void connectWithBYOK(String nsec) async {
final oauth = ref.read(oauthClientProvider);
// Pass nsec - the library derives byok_pubkey internally
final (url, verifier) = oauth.getAuthorizationUrl(
nsec: nsec, // e.g., "nsec1..."
scope: 'policy:social',
defaultRegister: true,
);
ref.read(pendingVerifierProvider.notifier).set(verifier);
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}KeycastRpc implements the NostrSigner interface, making it a drop-in replacement:
// Create RPC client from session
final session = await KeycastSession.load();
if (session == null || !session.hasRpcAccess) {
throw Exception('Not authenticated');
}
final config = ref.read(oauthConfigProvider);
final rpc = KeycastRpc.fromSession(config, session);
// Get public key
final pubkey = await rpc.getPublicKey();
// Sign an event
final event = Event(
kind: 1,
content: 'Hello from divine-mobile!',
tags: [],
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
pubkey: pubkey!,
);
final signedEvent = await rpc.signEvent(event);
// Encrypt (NIP-44)
final ciphertext = await rpc.nip44Encrypt(recipientPubkey, 'secret message');
// Decrypt (NIP-44)
final plaintext = await rpc.nip44Decrypt(senderPubkey, ciphertext);Sessions are automatically persisted using flutter_secure_storage:
// Save after successful OAuth
final session = KeycastSession.fromTokenResponse(tokenResponse);
await session.save();
// Load on app start
final session = await KeycastSession.load();
if (session != null && session.hasRpcAccess) {
// User is authenticated
}
// Clear on logout
await KeycastSession.clear();final session = await KeycastSession.load();
if (session == null) {
// Not logged in
} else if (session.isExpired) {
// Token expired - need to re-authenticate
await KeycastSession.clear();
} else if (session.hasRpcAccess) {
// Ready to use RPC
}class KeycastOAuth {
KeycastOAuth({required OAuthConfig config, http.Client? httpClient});
/// Generate authorization URL
/// Returns (url, verifier) - store verifier for token exchange
(String url, String verifier) getAuthorizationUrl({
String? nsec, // Optional: enables BYOK flow
String scope, // Default: 'policy:social'
bool defaultRegister, // Default: true
});
/// Exchange authorization code for tokens
Future<TokenResponse> exchangeCode({
required String code,
required String verifier,
});
/// Parse callback URL
CallbackResult parseCallback(String url);
}class KeycastRpc implements NostrSigner {
KeycastRpc({required String nostrApi, required String accessToken});
factory KeycastRpc.fromSession(OAuthConfig config, KeycastSession session);
Future<String?> getPublicKey();
Future<Event?> signEvent(Event event);
Future<String?> nip44Encrypt(String pubkey, String plaintext);
Future<String?> nip44Decrypt(String pubkey, String ciphertext);
Future<String?> encrypt(String pubkey, String plaintext); // NIP-04
Future<String?> decrypt(String pubkey, String ciphertext); // NIP-04
}class KeycastSession {
final String bunkerUrl;
final String? accessToken;
final DateTime? expiresAt;
final String? scope;
final String? userPubkey;
bool get isExpired;
bool get hasRpcAccess;
factory KeycastSession.fromTokenResponse(TokenResponse response);
Future<void> save();
static Future<KeycastSession?> load();
static Future<void> clear();
}The Keycast server at login.divine.video must be configured for both iOS and Android deep links. Server-side configuration is managed in the ../nos/keycast repository.
- AASA file:
https://login.divine.video/.well-known/apple-app-site-association - App IDs:
GZCZBKH7MY.co.openvine.keycastFlutterDemo,GZCZBKH7MY.co.openvine.divine - Callback path:
/app/callback
- Asset Links file:
https://login.divine.video/.well-known/assetlinks.json - Package names:
co.openvine.keycast_flutter_demo,co.openvine.divine
To add a new Android app, update assetlinks.json with the package name and SHA256 signing certificate fingerprint:
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "your.package.name",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}Get the SHA256 fingerprint from your signing keystore:
# Debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA256
# Release keystore
keytool -list -v -keystore your-release.keystore -alias your-alias | grep SHA256The library includes comprehensive tests:
cd packages/keycast_flutter
flutter test| Test File | Coverage |
|---|---|
pkce_test.dart |
PKCE verifier/challenge, BYOK embedding |
oauth_client_test.dart |
URL building, token exchange, callback parsing |
rpc_client_test.dart |
All RPC methods, error handling |
session_test.dart |
Persistence, expiry, factory methods |
key_utils_test.dart |
nsec parsing, pubkey derivation |
All HTTP calls are mocked using mocktail - no network required.
This error occurs when ASWebAuthenticationSession can't match the callback URL. Common causes:
1. Universal Links require Apple Developer Team membership. The demo's AASA file at login.divine.video is configured for our Team ID (GZCZBKH7MY). If you build with a different Team ID, Universal Links won't work.
2. macOS HTTPS callbacks don't work reliably. Due to Apple platform differences, ASWebAuthenticationSession.Callback.https behaves differently on macOS vs iOS:
- iOS 17.4+: HTTPS callbacks work correctly - the completion handler fires
- macOS 14.4+: The completion handler often doesn't fire. The redirect goes to the Universal Links handler instead, causing the "User canceled login" error
This is a known Apple platform behavior that cannot be fixed through configuration. The flutter_web_auth_2 plugin would need platform-specific code to handle this.
Solutions:
-
Use iOS for testing - iOS (simulator and device) works correctly with HTTPS callbacks
-
Read the code as reference - The demo is primarily documentation. Study how OAuth + PKCE + Universal Links work, then implement in your own app
-
Contact us - If you're integrating with Keycast and need your app's bundle ID added to the AASA, reach out
- Delete and reinstall the app
- iOS caches AASA files - wait a few minutes after server deploy
- Check entitlements are in the built app:
codesign -d --entitlements - Runner.app
Ensure redirectUri in OAuthConfig matches exactly what's registered on the server:
redirectUri: 'https://login.divine.video/app/callback' // Correct
redirectUri: 'https://login.divine.video/app/callback/' // Wrong (trailing slash)- Verify the
verifierstored matches what was used to generate the URL - Check the authorization
codehasn't expired (typically 10 minutes) - Ensure you're not reusing a code (single-use)
MIT