👽 Update the OIDC login

This commit is contained in:
LittleSheep 2025-06-18 01:45:53 +08:00
parent eb4d2c2e2f
commit 7f4e489f51
2 changed files with 296 additions and 196 deletions

View File

@ -19,6 +19,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart'; import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/screens/auth/oidc.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart'; import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
@ -174,6 +175,67 @@ class _LoginCheckScreen extends HookConsumerWidget {
return null; return null;
}, [isBusy]); }, [isBusy]);
Future<void> getToken({String? code}) async {
// Get token if challenge is completed
final client = ref.watch(apiClientProvider);
final tokenResp = await client.post(
'/auth/token',
data: {
'grant_type': 'authorization_code',
'code': code ?? challenge!.id,
},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
// Update the sessions' device name is available
if (!kIsWeb) {
String? name;
if (Platform.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
name = deviceInfo.name;
} else if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
name = deviceInfo.name;
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
name = deviceInfo.computerName;
}
if (name != null) {
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me/sessions/current/label',
data: jsonEncode(name),
);
}
}
}
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
isBusy.value = true;
getToken().catchError((err) {
showErrorAlert(err);
isBusy.value = false;
});
});
}
return null;
}, [challenge]);
Future<void> performCheckTicket() async { Future<void> performCheckTicket() async {
final pwd = passwordController.value.text; final pwd = passwordController.value.text;
if (pwd.isEmpty) return; if (pwd.isEmpty) return;
@ -192,47 +254,7 @@ class _LoginCheckScreen extends HookConsumerWidget {
return; return;
} }
// Get token if challenge is completed await getToken(code: result.id);
final tokenResp = await client.post(
'/auth/token',
data: {'grant_type': 'authorization_code', 'code': result.id},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
// Update the sessions' device name is available
if (!kIsWeb) {
String? name;
if (Platform.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
name = deviceInfo.name;
} else if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
name = deviceInfo.name;
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
name = deviceInfo.computerName;
}
if (name != null) {
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me/sessions/current/label',
data: jsonEncode(name),
);
}
}
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
return; return;
@ -346,6 +368,14 @@ class _LoginPickerScreen extends HookConsumerWidget {
return null; return null;
}, [isBusy]); }, [isBusy]);
useEffect(() {
if (ticket != null && ticket?.stepRemain == 0) {
onPickFactor(factors!.first);
onNext();
}
return null;
}, [ticket]);
final unfocusColor = Theme.of( final unfocusColor = Theme.of(
context, context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round()); ).colorScheme.onSurface.withAlpha((255 * 0.75).round());
@ -569,7 +599,6 @@ class _LoginLookupScreen extends HookConsumerWidget {
); );
if (context.mounted) showLoadingModal(context); if (context.mounted) showLoadingModal(context);
final resp = await client.post( final resp = await client.post(
'/auth/login/apple/mobile', '/auth/login/apple/mobile',
data: { data: {
@ -578,20 +607,18 @@ class _LoginLookupScreen extends HookConsumerWidget {
'device_id': await getUdid(), 'device_id': await getUdid(),
}, },
); );
final token = resp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks final challenge = SnAuthChallenge.fromJson(resp.data);
final userNotifier = ref.read(userInfoProvider.notifier); onChallenge(challenge);
userNotifier.fetchUser().then((_) { final factorResp = await client.get(
final apiClient = ref.read(apiClientProvider); '/auth/challenge/${challenge.id}/factors',
subscribePushNotification(apiClient); );
final wsNotifier = ref.read(websocketStateProvider.notifier); onFactor(
wsNotifier.connect(); List<SnAuthFactor>.from(
if (context.mounted) Navigator.pop(context, true); factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
}); ),
);
onNext();
} catch (err) { } catch (err) {
if (err is SignInWithAppleAuthorizationException) return; if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err); showErrorAlert(err);
@ -600,6 +627,32 @@ class _LoginLookupScreen extends HookConsumerWidget {
} }
} }
Future<void> withOidc(String provider) async {
final challengeId = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
),
);
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/auth/challenge/$challengeId');
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -635,6 +688,26 @@ class _LoginLookupScreen extends HookConsumerWidget {
Text("loginOr").tr().fontSize(11).opacity(0.85), Text("loginOr").tr().fontSize(11).opacity(0.85),
const Gap(8), const Gap(8),
Spacer(), Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: withApple, onPressed: withApple,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -27,6 +28,13 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
String? currentUrl; String? currentUrl;
final TextEditingController _urlController = TextEditingController(); final TextEditingController _urlController = TextEditingController();
bool _isLoading = true; bool _isLoading = true;
late Future<String> _deviceIdFuture;
@override
void initState() {
super.initState();
_deviceIdFuture = getUdid();
}
@override @override
void dispose() { void dispose() {
@ -43,155 +51,174 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
appBar: AppBar( appBar: AppBar(
title: widget.title != null ? Text(widget.title!) : Text('login').tr(), title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
), ),
body: Column( body: FutureBuilder<String>(
children: [ future: _deviceIdFuture,
Expanded( builder: (context, snapshot) {
child: InAppWebView( if (snapshot.connectionState == ConnectionState.waiting) {
initialSettings: InAppWebViewSettings( return const Center(child: CircularProgressIndicator());
userAgent: }
kIsWeb
? null if (snapshot.hasError) {
: Platform.isIOS return Center(child: Text('somethingWentWrong').tr());
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' }
: Platform.isAndroid
? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' final deviceId = snapshot.data!;
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
), return Column(
initialUrlRequest: URLRequest( children: [
url: WebUri('$serverUrl/auth/login/${widget.provider}'), Expanded(
headers: { child: InAppWebView(
if (token?.token.isNotEmpty ?? false) initialSettings: InAppWebViewSettings(
'Authorization': 'AtField ${token!.token}', userAgent:
}, kIsWeb
), ? null
onWebViewCreated: (controller) { : Platform.isIOS
// Register a handler to receive the token from JavaScript ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
controller.addJavaScriptHandler( : Platform.isAndroid
handlerName: 'tokenHandler', ? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
callback: (args) { : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
// args[0] will be the token string ),
if (args.isNotEmpty && args[0] is String) { initialUrlRequest: URLRequest(
url: WebUri('$serverUrl/auth/login/${widget.provider}'),
headers: {
if (token?.token.isNotEmpty ?? false)
'Authorization': 'AtField ${token!.token}',
'X-Device-Id': deviceId,
},
),
onWebViewCreated: (controller) {
// Register a handler to receive the token from JavaScript
controller.addJavaScriptHandler(
handlerName: 'tokenHandler',
callback: (args) {
// args[0] will be the token string
if (args.isNotEmpty && args[0] is String) {
setState(() {
authToken = args[0];
});
// Return the token and close the webview
Navigator.of(context).pop(authToken);
}
},
);
},
shouldOverrideUrlLoading: (
controller,
navigationAction,
) async {
final url = navigationAction.request.url;
if (url != null) {
setState(() { setState(() {
authToken = args[0]; currentUrl = url.toString();
_urlController.text = currentUrl ?? '';
_isLoading = true;
}); });
// Return the token and close the webview final path = url.path;
Navigator.of(context).pop(authToken); final queryParams = url.queryParameters;
// Check if we're on the token page
if (path.endsWith('/auth/callback')) {
// Extract token from URL
final challenge = queryParams['challenge'];
// Return the token and close the webview
Navigator.of(context).pop(challenge);
return NavigationActionPolicy.CANCEL;
}
}
return NavigationActionPolicy.ALLOW;
},
onUpdateVisitedHistory: (controller, url, androidIsReload) {
if (url != null) {
setState(() {
currentUrl = url.toString();
_urlController.text = currentUrl ?? '';
});
} }
}, },
); onLoadStop: (controller, url) {
}, setState(() {
shouldOverrideUrlLoading: (controller, navigationAction) async { _isLoading = false;
final url = navigationAction.request.url; });
if (url != null) { },
setState(() { onLoadStart: (controller, url) {
currentUrl = url.toString(); setState(() {
_urlController.text = currentUrl ?? ''; _isLoading = true;
_isLoading = true; });
}); },
onLoadError: (controller, url, code, message) {
final path = url.path; setState(() {
final queryParams = url.queryParameters; _isLoading = false;
});
// Check if we're on the token page },
if (path.contains('/auth/callback')) {
// Extract token from URL
final token = queryParams['token'] ?? true;
// Return the token and close the webview
Navigator.of(context).pop(token);
return NavigationActionPolicy.CANCEL;
}
}
return NavigationActionPolicy.ALLOW;
},
onUpdateVisitedHistory: (controller, url, androidIsReload) {
if (url != null) {
setState(() {
currentUrl = url.toString();
_urlController.text = currentUrl ?? '';
});
}
},
onLoadStop: (controller, url) {
setState(() {
_isLoading = false;
});
},
onLoadStart: (controller, url) {
setState(() {
_isLoading = true;
});
},
onLoadError: (controller, url, code, message) {
setState(() {
_isLoading = false;
});
},
),
),
// Loading progress indicator
if (_isLoading)
LinearProgressIndicator(
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.zero,
stopIndicatorRadius: 0,
minHeight: 2,
)
else
ColoredBox(
color: Theme.of(context).colorScheme.surfaceVariant,
).height(2),
// Debug location bar (only visible in debug mode)
Container(
padding: EdgeInsets.only(
left: 16,
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 8,
top: 8,
),
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
Expanded(
child: TextField(
controller: _urlController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
),
hintText: 'URL',
),
style: const TextStyle(fontSize: 12),
readOnly: true,
),
), ),
const Gap(4), ),
IconButton( // Loading progress indicator
icon: const Icon(Icons.copy, size: 20), if (_isLoading)
padding: const EdgeInsets.all(4), LinearProgressIndicator(
constraints: const BoxConstraints(), color: Theme.of(context).colorScheme.primary,
onPressed: () { backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
if (currentUrl != null) { borderRadius: BorderRadius.zero,
Clipboard.setData(ClipboardData(text: currentUrl!)); stopIndicatorRadius: 0,
ScaffoldMessenger.of(context).showSnackBar( minHeight: 2,
SnackBar( )
content: Text('copyToClipboard').tr(), else
duration: const Duration(seconds: 1), ColoredBox(
color: Theme.of(context).colorScheme.surfaceVariant,
).height(2),
// Debug location bar (only visible in debug mode)
Container(
padding: EdgeInsets.only(
left: 16,
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 8,
top: 8,
),
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
Expanded(
child: TextField(
controller: _urlController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
),
hintText: 'URL',
), ),
); style: const TextStyle(fontSize: 12),
} readOnly: true,
}, ),
),
const Gap(4),
IconButton(
icon: const Icon(Icons.copy, size: 20),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: () {
if (currentUrl != null) {
Clipboard.setData(ClipboardData(text: currentUrl!));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('copyToClipboard').tr(),
duration: const Duration(seconds: 1),
),
);
}
},
),
],
), ),
], ),
), ],
), );
], },
), ),
); );
} }