forked from baron/baron-sso
only JWT 발급
This commit is contained in:
@@ -23,6 +23,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _smsCodeController = TextEditingController();
|
||||
bool _smsSent = false;
|
||||
String? _redirectUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,24 +36,27 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
try {
|
||||
// Use Proxy to verify token
|
||||
// Use Backend to verify the token (Backend-Driven Flow)
|
||||
// The backend will validate the local token, and then trigger Descope JWT generation.
|
||||
// This approves the pending session for the Polling device.
|
||||
await AuthProxyService.verifyMagicLink(token);
|
||||
|
||||
// Note: If this device (Mobile) also needs to login, we would need to
|
||||
// parse the response from verifyMagicLink which contains the JWT.
|
||||
// For now, we assume this action primarily approves the PC session.
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -86,8 +90,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
final password = _passwordController.text;
|
||||
if (password.isNotEmpty) {
|
||||
// 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.password.signIn(
|
||||
loginId: email,
|
||||
@@ -95,197 +97,197 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
final session = DescopeSession.fromAuthenticationResponse(authResponse);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
if (mounted) _onLoginSuccess(session.sessionToken.jwt);
|
||||
} catch (e) {
|
||||
_showError("Email/Password Login Failed: $e");
|
||||
}
|
||||
} else {
|
||||
// Enchanted Link Flow (via Proxy)
|
||||
try {
|
||||
// 1. Init via Proxy
|
||||
final initData = await AuthProxyService.initEnchantedLink(email);
|
||||
final linkId = initData['linkId'];
|
||||
final pendingRef = initData['pendingRef'];
|
||||
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Check your Email"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 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',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
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.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");
|
||||
}
|
||||
// Email Enchanted Link (Descope Standard)
|
||||
_initiateDescopeLinkFlow(email, isSms: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _handleSmsLogin() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
final rawPhone = _phoneController.text.trim();
|
||||
if (rawPhone.isEmpty) return;
|
||||
|
||||
print("[Frontend] SMS 코드 발송 시작. 번호: $phone");
|
||||
// Sanitize phone number
|
||||
String phone = rawPhone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
// Ensure 010 format if needed, but backend handles it too
|
||||
|
||||
try {
|
||||
await AuthProxyService.sendSms(phone);
|
||||
print("[Frontend] SMS 코드 발송 요청 성공.");
|
||||
setState(() {
|
||||
_smsSent = true;
|
||||
});
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Init via Backend API (Not Descope SDK)
|
||||
final initResponse = await AuthProxyService.initEnchantedLink(phone);
|
||||
final pendingRef = initResponse['pendingRef'];
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Loading
|
||||
|
||||
// Show Waiting Dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("SMS Sent"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Please check the link sent to your phone."),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // Allow canceling
|
||||
},
|
||||
child: const Text("Cancel")
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Poll Backend manually
|
||||
_pollForSession(pendingRef);
|
||||
}
|
||||
} catch (e) {
|
||||
print("[Frontend] SMS 코드 발송 요청 실패: $e");
|
||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
||||
_showError("Failed to send SMS: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSmsVerification() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
final code = _smsCodeController.text.trim();
|
||||
if (phone.isEmpty || code.isEmpty) return;
|
||||
Future<void> _pollForSession(String pendingRef) async {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 60; // 2 minutes
|
||||
|
||||
print("[Frontend] SMS 코드 검증 시작. 번호: $phone, 코드: $code");
|
||||
while (attempts < maxAttempts && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
|
||||
|
||||
if (result['status'] == 'ok') {
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
// Note: Manually constructing DescopeSession can be complex due to abstract classes.
|
||||
// In a real production app, you should use the SDK's built-in exchange/verify methods.
|
||||
// For this prototype, we will proceed with the login success callback.
|
||||
// If session management is required immediately, we'd need to match the specific
|
||||
// SDK version's Token implementation.
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("Polling error: $e");
|
||||
// Continue polling even on temporary network error?
|
||||
// Or break? Let's continue.
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_showError("Login timed out.");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initiateDescopeLinkFlow(String loginId, {required bool isSms}) async {
|
||||
try {
|
||||
final result = await AuthProxyService.verifySmsCode(phone, code);
|
||||
final token = result['token'];
|
||||
print("[Frontend] SMS 코드 검증 성공. JWT 수신: $token");
|
||||
// TODO: Handle the JWT token from the result, e.g., result['token']
|
||||
_showSuccessDialog();
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Init via Descope SDK
|
||||
final signUpOrInResponse = await Descope.enchantedLink.signUpOrIn(
|
||||
loginId: loginId,
|
||||
redirectUrl: "http://ssologin.hmac.kr/auth/callback",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Loading
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isSms ? "SMS Sent" : "Email Sent"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("We sent a login link to ${isSms ? loginId.split('@')[0] : loginId}"),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// For SMS, we might not get a Link ID in the message if the template doesn't include it.
|
||||
// But Enchanted Link always has one.
|
||||
Text(
|
||||
"Security Number: ${signUpOrInResponse.linkId}",
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.blue),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text("Click the matching number."),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Poll via Descope SDK
|
||||
final authResponse = await Descope.enchantedLink.pollForSession(pendingRef: signUpOrInResponse.pendingRef);
|
||||
final session = DescopeSession.fromAuthenticationResponse(authResponse);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Dialog
|
||||
_onLoginSuccess(session.sessionToken.jwt);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("[Frontend] SMS 코드 검증 실패: $e");
|
||||
_showError("Failed to verify code: $e");
|
||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
||||
_showError("Login Failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _onLoginSuccess(String token) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
_showError("Login Successful! You can close this window.");
|
||||
} else if (_redirectUrl != null) {
|
||||
final target = "$_redirectUrl?token=$token";
|
||||
launchUrlString(target, webOnlyWindowName: '_self');
|
||||
} else {
|
||||
_showError("Login Successful (Token Received)");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +312,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Tab Bar
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
@@ -320,13 +321,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Tab View Content
|
||||
SizedBox(
|
||||
height: 300, // Slightly increased height for content
|
||||
height: 300,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Email/Password Form
|
||||
// Email Form
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
@@ -342,7 +342,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
labelText: "Password (Optional)",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
@@ -353,15 +353,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("Sign In"),
|
||||
child: const Text("Sign In / Send Link"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Phone/SMS Form
|
||||
// Phone Form
|
||||
Column(
|
||||
children: [
|
||||
if (!_smsSent) ...[
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -377,26 +376,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("Send Verification Code"),
|
||||
child: const Text("Send Login Link"),
|
||||
),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _smsCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Verification Code",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.password),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"We will send a login link to your phone via SMS.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handleSmsVerification,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("Verify Code"),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -408,4 +395,4 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user