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
107 changes: 107 additions & 0 deletions poc/flutter-poc/assets/poc-simulation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{
"version": 1,
"description": "PoC bundle simulator: ordered mock + Morph host steps. Edit to add POST bodies, paths, or conditional blocks.",
"mockApi": {
"baseUrl": "http://localhost:3000",
"envOverride": "VITE_MOCK_API_BASE"
},
"sessionDeadCheck": {
"authIds": ["morph-auth/1fa", "morph-auth/2fa"],
"message": "Keycloak session is dead (refresh: invalid_grant / Token is not active). Simulation stopped — sign in again from Home."
},
"steps": [
{ "type": "fetch", "label": "GET /public/config", "path": "/public/config" },
{ "type": "fetch", "label": "GET /health", "path": "/health" },
{
"type": "host",
"label": "GET /profile (1fa)",
"hostKey": "main-api",
"method": "GET",
"path": "/profile",
"auth": "morph-auth/1fa"
},
{
"type": "host",
"label": "GET /accounts (2fa)",
"hostKey": "main-api",
"method": "GET",
"path": "/accounts",
"auth": "morph-auth/2fa"
},
{
"type": "host",
"label": "POST /transfers (2fa)",
"hostKey": "main-api",
"method": "POST",
"path": "/transfers",
"auth": "morph-auth/2fa",
"body": {
"fromAccount": "ACC-001",
"toAccount": "ACC-002",
"amount": 1,
"currency": "TRY"
}
},
{
"type": "host",
"label": "GET /profile + per-call headers",
"hostKey": "main-api",
"method": "GET",
"path": "/profile",
"auth": "morph-auth/1fa",
"headers": { "X-Poc-Sim-Extra": "static-from-json" },
"tickHeaderName": "X-Poc-Sim-Tick"
},
{
"type": "host",
"label": "GET /public/config (device bearer)",
"hostKey": "main-api",
"method": "GET",
"path": "/public/config",
"auth": "morph-auth/device"
},
{
"type": "logout_provider",
"label": "Logout morph-auth",
"providerKey": "morph-auth"
}
],
"conditionalBlocks": [
{
"id": "google_verify",
"when": {
"type": "all",
"all": [
{ "type": "provider_env_ready", "providerKey": "google-auth" },
{ "type": "has_valid_token", "authId": "google-auth/google" }
]
},
"skipRow": {
"label": "GET /identity/verify (Google)",
"detail": "No Google session — use “Google login” on Home first (AUTH here would be normal)."
},
"steps": [
{
"type": "host",
"label": "GET /identity/verify (Google)",
"hostKey": "main-api",
"method": "GET",
"path": "/identity/verify",
"auth": "google-auth/google"
}
]
},
{
"id": "probe_404",
"when": { "type": "ui_flag_probe_404" },
"steps": [
{
"type": "fetch",
"label": "GET /sim/not-found (expect 404)",
"path": "/sim/not-found",
"expectStatus": 404
}
]
}
]
}
75 changes: 67 additions & 8 deletions poc/flutter-poc/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,45 +1,104 @@
import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:morph_core/morph_core.dart';

import 'morph_init.dart';
import 'screens/home_screen.dart';

/// Captures the OAuth params from the URL **synchronously** before runApp,
/// so we can clear them from `Uri.base` and process them after the UI mounts.
({String? code, String? state})? _captureWebOAuthParams() {
if (!kIsWeb) return null;
final uri = Uri.base;
final code = uri.queryParameters['code'];
final state = uri.queryParameters['state'];
if (code == null || state == null) return null;
return (code: code, state: state);
}

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// ignore: avoid_print
print('[morph-poc] main() start — ${Uri.base}');

final pendingOAuth = _captureWebOAuthParams();
// ignore: avoid_print
print('[morph-poc] OAuth params present? ${pendingOAuth != null}');

final morph = await initMorph();
// ignore: avoid_print
print('[morph-poc] initMorph() done — runApp now');

runApp(MorphPocApp(morph: morph));
// Render UI immediately; HomeScreen will process the OAuth callback in initState.
runApp(MorphPocApp(morph: morph, pendingOAuth: pendingOAuth));
// ignore: avoid_print
print('[morph-poc] runApp returned');
}

class MorphPocApp extends StatefulWidget {
const MorphPocApp({super.key, required this.morph});
const MorphPocApp({super.key, required this.morph, this.pendingOAuth});

final MorphClient morph;
final ({String? code, String? state})? pendingOAuth;

@override
State<MorphPocApp> createState() => _MorphPocAppState();
}

class _MorphPocAppState extends State<MorphPocApp> {
late final AppLinks _appLinks;
AppLinks? _appLinks;
StreamSubscription<Uri>? _linkSubscription;
String? _pendingOAuthMessage;
final GlobalKey<HomeScreenState> _homeKey = GlobalKey<HomeScreenState>();

@override
void initState() {
super.initState();
_appLinks = AppLinks();
_listenForDeepLinks();
// ignore: avoid_print
print('[morph-poc] _MorphPocAppState.initState');
if (!kIsWeb) {
_appLinks = AppLinks();
_linkSubscription = _appLinks!.uriLinkStream.listen(_handleIncomingUri);
} else if (widget.pendingOAuth != null) {
// Process web OAuth callback AFTER the UI mounted, so a hang/error
// here does not prevent runApp from rendering.
WidgetsBinding.instance.addPostFrameCallback((_) {
_processPendingWebOAuth();
});
}
}

void _listenForDeepLinks() {
_linkSubscription = _appLinks.uriLinkStream.listen(_handleIncomingUri);
Future<void> _processPendingWebOAuth() async {
final p = widget.pendingOAuth!;
// ignore: avoid_print
print('[morph-poc] _processPendingWebOAuth: calling completeOAuthCallback…');
try {
final result = await widget.morph.completeOAuthCallback(
code: p.code,
state: p.state,
);
// ignore: avoid_print
print('[morph-poc] completeOAuthCallback DONE: ${result.status} / ${result.message}');
if (mounted) {
setState(() => _pendingOAuthMessage =
'OAuth complete: ${result.status}${result.message != null ? ' — ${result.message}' : ''}');
// Trigger HomeScreen to re-read token status after OAuth completes.
await _homeKey.currentState?.refreshStatus();
}
} catch (e, st) {
// ignore: avoid_print
print('[morph-poc] completeOAuthCallback THREW: $e\n$st');
if (mounted) {
setState(() => _pendingOAuthMessage = 'OAuth callback error: $e');
}
}
}

/// Mobile/desktop: handles deep-link OAuth callbacks via app_links.
Future<void> _handleIncomingUri(Uri uri) async {
if (!uri.toString().startsWith(kOAuthCallbackUri)) return;

Expand Down Expand Up @@ -93,7 +152,7 @@ class _MorphPocAppState extends State<MorphPocApp> {
setState(() => _pendingOAuthMessage = null);
});
}
return HomeScreen(morph: widget.morph);
return HomeScreen(key: _homeKey, morph: widget.morph);
},
),
);
Expand Down
74 changes: 52 additions & 22 deletions poc/flutter-poc/lib/morph_init.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/services.dart';
import 'package:morph_core/morph_core.dart';
import 'package:morph_core_storage/morph_core_storage.dart';
Expand All @@ -11,9 +12,22 @@ import 'package:morph_oauth2/morph_oauth2.dart';
import 'package:morph_storage/memory_storage_plugin.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Deep-link URI scheme used by the Flutter PoC.
/// Deep-link URI scheme used by the Flutter PoC on mobile/desktop.
const String kOAuthCallbackUri = 'morphpoc://oauth/callback';

/// Returns the mock API base URL for the current platform.
/// Android emulators use 10.0.2.2 to reach the host machine's localhost.
String getMockApiBase() {
final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
? '10.0.2.2'
: 'localhost';
return 'http://$host:3000';
}

/// Redirect URI used when running as a Flutter web app on Chrome.
/// Must be registered in Keycloak's morph-login client redirectUris.
const String kWebOAuthCallbackUri = 'http://localhost:4200/';

/// Prefix for SharedPreferences keys.
const String _kPrefsPrefix = 'morph-poc:';

Expand All @@ -23,10 +37,13 @@ final List<String> morphLogLines = [];
/// HTTP trace events collected via [MorphOptions.onHttpTrace]; consumed by the UI.
final List<MorphHttpTraceEvent> morphHttpTraces = [];

/// Shared log appender — consistent format used by both MorphOptions.onLog
/// and ContextStoreOptions.onLog.
/// Shared log appender — writes to browser console AND keeps the in-memory
/// ring buffer (consumed by UI widgets).
// ignore: avoid_print
void _appendLog(String level, String message, [Object? error]) {
final entry = '[$level] $message${error != null ? ' — $error' : ''}';
final entry = '[morph][$level] $message${error != null ? ' — $error' : ''}';
// ignore: avoid_print
print(entry);
morphLogLines.add(entry);
if (morphLogLines.length > 300) morphLogLines.removeAt(0);
}
Expand All @@ -40,22 +57,32 @@ Future<MorphClient> initMorph() async {

final logger = loggerPlugin(const LoggerPluginOptions(level: 'debug'));

// Persistent, boundary-scoped, optionally encrypted storage via morph_data_store.
// Falls back to in-memory storage if ContextStore initialization fails
// (e.g., Keychain unavailable in simulator without entitlements).
// Storage strategy:
// • Web: in-memory only. ContextStore requires an active user identity
// (Boundary.user) to build storage keys, but on web the OAuth redirect
// reloads the page — so the identity is never set before the token write.
// In-memory storage is correct here: completeOAuthCallback() runs in main()
// before runApp(), so the token lives in the same JS heap the app reads from.
// • Native: ContextStore (persistent, boundary-scoped). Deep-link callbacks
// do NOT reload the process, so identity can be set before token storage.
MorphPlugin storagePlugin;
try {
final contextStore = await ContextStore.create(ContextStoreOptions(
onRequestServerTime: (_, __) async => null,
timeServerUrls: [],
onLog: (level, message, [err, ctx]) =>
_appendLog(level.name, message, err),
));
storagePlugin = contextStoreStoragePlugin(contextStore);
_appendLog('info', 'Storage: ContextStore (persistent)');
} catch (e) {
if (kIsWeb) {
storagePlugin = memoryStorageMorphPlugin();
_appendLog('warn', 'Storage: fallback to in-memory — ContextStore init failed', e);
_appendLog('info', 'Storage: in-memory (web — ContextStore requires user identity)');
} else {
try {
final contextStore = await ContextStore.create(ContextStoreOptions(
onRequestServerTime: (_, __) async => null,
timeServerUrls: [],
onLog: (level, message, [err, ctx]) =>
_appendLog(level.name, message, err),
));
storagePlugin = contextStoreStoragePlugin(contextStore);
_appendLog('info', 'Storage: ContextStore (persistent)');
} catch (e) {
storagePlugin = memoryStorageMorphPlugin();
_appendLog('warn', 'Storage: fallback to in-memory — ContextStore init failed', e);
}
}

final options = MorphOptions(
Expand Down Expand Up @@ -95,7 +122,10 @@ Future<Map<String, String>> _buildVariables() async {

// On Android emulators, localhost refers to the emulator's own loopback.
// Use 10.0.2.2 to reach the host machine's localhost instead.
final host = Platform.isAndroid ? '10.0.2.2' : 'localhost';
// On web (Chrome), localhost is always correct since the browser runs on the host.
final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
? '10.0.2.2'
: 'localhost';
final keycloakBase =
'http://$host:8080/realms/morph/protocol/openid-connect';
final mockApiBase = 'http://$host:3000';
Expand Down Expand Up @@ -126,8 +156,8 @@ Future<Map<String, String>> _buildVariables() async {
'pocGoogleTokenEndpoint': 'https://oauth2.googleapis.com/token',
'googleClientId': const String.fromEnvironment('GOOGLE_CLIENT_ID'),
'googleClientSecret': const String.fromEnvironment('GOOGLE_CLIENT_SECRET'),
// OAuth redirect — custom scheme handled by app_links.
'oauthCallbackUri': kOAuthCallbackUri,
// OAuth redirect — HTTP redirect on web, custom scheme on mobile/desktop.
'oauthCallbackUri': kIsWeb ? kWebOAuthCallbackUri : kOAuthCallbackUri,
};
}

Expand Down
Loading
Loading