1
0
forked from baron/baron-sso

namecard 연동

This commit is contained in:
2026-01-06 09:49:11 +09:00
parent c56368d1cb
commit c512f0f4e6
13 changed files with 693 additions and 50 deletions

View File

@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuditService {
static final String _baseUrl = dotenv.env['BACKEND_URL'] ?? 'http://localhost:3000';
static Future<void> logEvent({
required String userId,
required String eventType,
required String status,
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'user_id': userId,
'event_type': eventType,
'status': status,
'details': details,
'timestamp': DateTime.now().toIso8601String(),
}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
print("Audit log sent successfully");
} else {
print("Failed to send audit log: ${response.statusCode} ${response.body}");
}
} catch (e) {
print("Error sending audit log: $e");
}
}
}

View File

@@ -0,0 +1,76 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuthProxyService {
static final String _baseUrl = dotenv.env['BACKEND_URL'] ?? 'http://localhost:3000';
static Future<Map<String, dynamic>> initEnchantedLink(String loginId) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'loginId': loginId,
'uri': 'http://localhost:5000', // Use 5000 as it's definitely allowed
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to init login: ${response.body}');
}
}
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Polling failed: ${response.body}');
}
}
static Future<void> verifyMagicLink(String token) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
}),
);
if (response.statusCode != 200) {
throw Exception('Verification failed: ${response.body}');
}
}
static Future<void> logError(String message) async {
final url = Uri.parse('$_baseUrl/api/v1/client-log');
try {
await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'level': 'ERROR',
'message': message,
}),
);
} catch (_) {
// Ignore logging errors to prevent loops
}
}
}

View File

@@ -0,0 +1,13 @@
import 'web_auth_integration_stub.dart'
if (dart.library.html) 'web_auth_integration_web.dart';
abstract class WebAuthIntegration {
static void sendLoginSuccess(String token) {
// Platform-specific implementation
implSendLoginSuccess(token);
}
static bool isPopup() {
return implIsPopup();
}
}

View File

@@ -0,0 +1,8 @@
void implSendLoginSuccess(String token) {
// No-op on non-web platforms
print("Not on web: Login Success with token: $token");
}
bool implIsPopup() {
return false;
}

View File

@@ -0,0 +1,37 @@
import 'dart:html' as html;
void implSendLoginSuccess(String token) {
final message = {'type': 'LOGIN_SUCCESS', 'token': token};
bool sent = false;
// 1. Try postMessage
if (html.window.opener != null) {
try {
html.window.opener!.postMessage(message, '*');
sent = true;
print("Sent login success message to opener");
} catch (e) {
print("Failed to postMessage: $e");
}
// 2. Fallback: Redirect opener directly (Force refresh with token)
try {
// Only redirect if it's localhost:8000 to be safe, or just do it.
// This will cause the parent window to reload, which is fine for login.
html.window.opener!.location.href = "http://localhost:8000?token=$token";
sent = true;
} catch (e) {
print("Failed to redirect opener: $e");
}
}
if (!sent) {
print("No opener found. Redirecting current window to target.");
// Fallback: Redirect THIS window to localhost:8000 with token
html.window.location.href = "http://localhost:8000?token=$token";
}
}
bool implIsPopup() {
return html.window.opener != null;
}

View File

@@ -3,6 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../../core/services/audit_service.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -22,6 +26,46 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// Check for 't' token in URL (Magic Link / Enchanted Link verification)
WidgetsBinding.instance.addPostFrameCallback((_) {
final uri = Uri.base;
if (uri.queryParameters.containsKey('t')) {
_verifyToken(uri.queryParameters['t']!);
}
});
}
Future<void> _verifyToken(String token) async {
try {
// Use Proxy to verify token
await AuthProxyService.verifyMagicLink(token);
if (mounted) {
_showSuccessDialog();
}
} catch (e) {
// Ignore "Missing session JWT" if it happens (though proxy might handle it differently)
if (e.toString().contains("Missing session JWT")) {
if (mounted) _showSuccessDialog();
return;
}
if (mounted) {
_showError("Verification failed: $e");
}
}
}
void _showSuccessDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
title: Text("Authentication Successful"),
content: Text("You can close this tab and return to the application."),
),
);
}
@override
@@ -37,35 +81,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final email = _emailController.text.trim();
if (email.isEmpty) return;
// Determine if it's Password or Enchanted Link flow
// For this PoC, we'll try Enchanted Link as primary for 'Email' tab per requirements,
// but the UI has a password field. Let's support both based on input.
// However, PRD says Primary is Email/Password.
final password = _passwordController.text;
if (password.isNotEmpty) {
// Email + Password Flow
// Email + Password Flow (Keep SDK as is, assuming Password flow might work or fail same way.
// If password flow fails too, we need proxy for that as well. But let's focus on Enchanted Link first as requested.)
try {
final authResponse = await Descope.auth.password.signIn(
final authResponse = await Descope.password.signIn(
loginId: email,
password: password,
);
final session = DescopeSession.fromAuthenticationResponse(authResponse);
Descope.sessionManager.manageSession(session);
if (mounted) context.go('/dashboard');
await AuditService.logEvent(
userId: session.user?.userId ?? email,
eventType: 'login_success',
status: 'success',
details: 'Method: Email/Password',
);
if (mounted) {
final token = session.sessionToken.jwt;
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(token);
_showError("Login Successful! You can close this window.");
} else {
context.go('/dashboard');
}
}
} catch (e) {
_showError("Email/Password Login Failed: $e");
}
} else {
// Enchanted Link Flow (Passwordless)
// Enchanted Link Flow (via Proxy)
try {
// Start Enchanted Link
final response = await Descope.auth.enchantedLink.signUpOrIn(
loginId: email,
uri: "baronsso://auth", // Deep link for the 'Clicked' device
);
// 1. Init via Proxy
final initData = await AuthProxyService.initEnchantedLink(email);
final linkId = initData['linkId'];
final pendingRef = initData['pendingRef'];
// Show Polling Dialog
if (mounted) {
showDialog(
context: context,
@@ -77,46 +131,111 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [
Text("We sent an email to $email"),
const SizedBox(height: 16),
Text(
"Security Number: $linkId",
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.blue),
),
const SizedBox(height: 8),
const Text("Click the matching number in your email."),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
Text("Link: ${response.linkId}"), // Display for debug/PoC
],
),
),
);
// Poll for completion
final authResponse = await Descope.auth.enchantedLink.poll(
response.pendingRef,
// 2. Poll via Proxy (Loop until success or timeout)
String sessionToken = "";
int attempts = 0;
const maxAttempts = 60; // 2 minutes (assuming 2s delay)
while (attempts < maxAttempts && mounted) {
attempts++;
try {
final pollData = await AuthProxyService.pollEnchantedLink(pendingRef);
// Send log to backend
// AuthProxyService.logError("[DEBUG] Poll response keys: ${pollData.keys.toList()}");
// Descope API returns 'sessionJwt', not 'sessionToken'
var tokenObj = pollData['sessionJwt'] ?? pollData['sessionToken'];
if (tokenObj != null) {
if (tokenObj is Map) {
sessionToken = tokenObj['jwt'] ?? "";
} else if (tokenObj is String) {
sessionToken = tokenObj;
}
}
if (sessionToken.isNotEmpty) {
break; // Success!
}
} catch (e) {
// Check if it's the "pending" error. If so, continue.
// The error message from backend is likely a string in exception.
// A robust implementation would parse the error code.
// For PoC, we just assume any error means "not ready yet" unless it's a fatal one.
// Let's print debug but continue.
print("Polling attempt $attempts: Waiting... ($e)");
}
await Future.delayed(const Duration(seconds: 2));
}
if (sessionToken.isEmpty) {
throw Exception("Polling timed out or failed.");
}
// Note: pollData structure depends on what Descope API returns.
// Usually it returns full auth response.
// Let's assume we get the JWT string directly or extract it.
// The proxy just forwards the JSON. Descope /poll returns standard auth info.
// Manually handle session if needed or just use token.
// For PoC, we prioritize token handoff.
await AuditService.logEvent(
userId: email, // We might not have full user object yet
eventType: 'login_success',
status: 'success',
details: 'Method: Email/EnchantedLink/Proxy',
);
final session = DescopeSession.fromAuthenticationResponse(
authResponse,
);
Descope.sessionManager.manageSession(session);
if (mounted) {
Navigator.of(context).pop(); // Close Dialog
context.go('/dashboard');
Navigator.of(context).pop(); // Close Dialog
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(sessionToken);
_showError("Login Successful! You can close this window.");
} else {
// For dashboard, we might need to properly init Descope session.
// Since we bypassed SDK, Descope.sessionManager.session is null.
// We can try to hydrate it if SDK allows, or just ignore for now if this is primarily a Launcher.
_showError("Login Successful (Standalone mode limited without SDK session)");
// context.go('/dashboard');
}
}
}
} catch (e) {
if (mounted) Navigator.of(context).pop(); // Close dialog if open
_showError("Enchanted Link Failed: $e");
if (mounted && Navigator.canPop(context)) {
// Close dialog if open? logic is tricky without state, but let's assume error means stop.
Navigator.of(context).pop();
}
_showError("Enchanted Link Failed (Proxy): $e");
}
}
}
Future<void> _handleSmsLogin() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
try {
// Enchanted Link via SMS (Polling)
// Note: This assumes Descope project is configured to send SMS for this loginId
final response = await Descope.auth.enchantedLink.signUpOrIn(
loginId: phone,
uri: "baronsso://auth", // Link for the device that receives SMS
);
// 1. Init via Proxy
final initData = await AuthProxyService.initEnchantedLink(phone);
final pendingRef = initData['pendingRef'];
if (mounted) {
showDialog(
@@ -131,35 +250,70 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
// Text("Link: ${response.linkId}"), // Debug
],
),
),
);
// Poll for completion
final authResponse = await Descope.auth.enchantedLink.poll(
response.pendingRef,
// 2. Poll via Proxy
final pollData = await AuthProxyService.pollEnchantedLink(pendingRef);
String sessionToken = "";
if (pollData['sessionToken'] is Map) {
sessionToken = pollData['sessionToken']['jwt'] ?? "";
} else if (pollData['sessionToken'] is String) {
sessionToken = pollData['sessionToken'];
}
if (sessionToken.isEmpty) {
throw Exception("Invalid session token received");
}
await AuditService.logEvent(
userId: phone,
eventType: 'login_success',
status: 'success',
details: 'Method: SMS/EnchantedLink/Proxy',
);
final session = DescopeSession.fromAuthenticationResponse(authResponse);
Descope.sessionManager.manageSession(session);
if (mounted) {
Navigator.of(context).pop(); // Close Dialog
context.go('/dashboard');
Navigator.of(context).pop(); // Close Dialog
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(sessionToken);
_showError("Login Successful! You can close this window.");
} else {
_showError("Login Successful (Standalone)");
}
}
}
} catch (e) {
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
_showError("SMS Enchanted Link Failed: $e");
_showError("SMS Enchanted Link Failed (Proxy): $e");
}
}
void _showError(String message) {
if (!mounted) return;
// Show Snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
// Send log to backend for Docker visibility
try {
// Use AuthProxyService base URL logic or dotenv, but for simplicity here use relative or direct.
// Since we are in the same network context as Proxy, we can assume localhost:3000 or relative path if deployed.
// But Flutter Web runs in browser, so we need the full URL reachable from browser.
// We'll use the same host logic as AuthProxyService (which uses dotenv BACKEND_URL).
// Since we can't easily import http here without clutter, we'll invoke a helper method if available,
// or just add the http call here. We already import AuthProxyService.
// Let's add a log method to AuthProxyService to keep it clean.
AuthProxyService.logError(message);
} catch (e) {
print("Failed to send log to backend: $e");
}
}
@override

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
Future<void> _logout(BuildContext context) async {
Descope.sessionManager.clearSession();
if (context.mounted) context.go('/');
}
@override
Widget build(BuildContext context) {
final user = Descope.sessionManager.session?.user;
final userName = user?.name ?? user?.email ?? user?.phone ?? 'User';
return Scaffold(
appBar: AppBar(
title: Text('Baron Launcher', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _logout(context),
tooltip: 'Sign Out',
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Dashboard Loaded Successfully', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Text('Welcome, $userName'),
],
),
),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'features/auth/presentation/login_screen.dart';
import 'features/dashboard/presentation/dashboard_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -21,7 +22,11 @@ void main() async {
Descope.setup(projectId);
// Load saved session if any
await Descope.sessionManager.loadSession();
try {
await Descope.sessionManager.loadSession();
} catch (e) {
debugPrint("Failed to load session: $e");
}
runApp(const ProviderScope(child: BaronSSOApp()));
}
@@ -33,8 +38,7 @@ final _router = GoRouter(
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/dashboard',
builder: (context, state) =>
const Scaffold(body: Center(child: Text("Dashboard Placeholder"))),
builder: (context, state) => const DashboardScreen(),
),
],
redirect: (context, state) {

View File

@@ -65,9 +65,8 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- .env
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images