♻️ Rework of the oidc login flow (wip)
This commit is contained in:
@@ -1,22 +1,71 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:email_validator/email_validator.dart';
|
import 'package:email_validator/email_validator.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/screens/account/me/profile_update.dart';
|
import 'package:island/screens/account/me/profile_update.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
|
import 'package:island/services/notify.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import 'captcha.dart';
|
import 'captcha.dart';
|
||||||
|
|
||||||
|
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
|
||||||
|
final providerLower = provider.toLowerCase();
|
||||||
|
|
||||||
|
// Check if we have an SVG for this provider
|
||||||
|
switch (providerLower) {
|
||||||
|
case 'apple':
|
||||||
|
case 'microsoft':
|
||||||
|
case 'google':
|
||||||
|
case 'github':
|
||||||
|
case 'discord':
|
||||||
|
case 'afdian':
|
||||||
|
case 'steam':
|
||||||
|
return SvgPicture.asset(
|
||||||
|
'assets/images/oidc/$providerLower.svg',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter:
|
||||||
|
color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null,
|
||||||
|
);
|
||||||
|
case 'spotify':
|
||||||
|
return Image.asset(
|
||||||
|
'assets/images/oidc/spotify.png',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return Icon(Symbols.link, size: size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class CreateAccountContent extends HookConsumerWidget {
|
class CreateAccountContent extends HookConsumerWidget {
|
||||||
const CreateAccountContent({super.key});
|
const CreateAccountContent({super.key});
|
||||||
|
|
||||||
|
Map<String, dynamic> decodeJwt(String token) {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length != 3) throw FormatException('Invalid JWT');
|
||||||
|
final payload = parts[1];
|
||||||
|
final normalized = base64Url.normalize(payload);
|
||||||
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||||
|
return json.decode(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
||||||
@@ -25,6 +74,8 @@ class CreateAccountContent extends HookConsumerWidget {
|
|||||||
final usernameController = useTextEditingController();
|
final usernameController = useTextEditingController();
|
||||||
final nicknameController = useTextEditingController();
|
final nicknameController = useTextEditingController();
|
||||||
final passwordController = useTextEditingController();
|
final passwordController = useTextEditingController();
|
||||||
|
final waitingForOidc = useState(false);
|
||||||
|
final onboardingToken = useState<String?>(null);
|
||||||
|
|
||||||
void showPostCreateModal() {
|
void showPostCreateModal() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -37,38 +88,129 @@ class CreateAccountContent extends HookConsumerWidget {
|
|||||||
void performAction() async {
|
void performAction() async {
|
||||||
if (!formKey.currentState!.validate()) return;
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
final captchaTk = await CaptchaScreen.show(context);
|
String endpoint = '/pass/accounts';
|
||||||
if (captchaTk == null) return;
|
Map<String, dynamic> data = {};
|
||||||
|
|
||||||
|
if (onboardingToken.value != null) {
|
||||||
|
// OIDC onboarding
|
||||||
|
endpoint = '/pass/account/onboard';
|
||||||
|
data['onboarding_token'] = onboardingToken.value;
|
||||||
|
data['name'] = usernameController.text;
|
||||||
|
data['nick'] = nicknameController.text;
|
||||||
|
// Password is required in form, but might be optional
|
||||||
|
} else {
|
||||||
|
// Manual account creation
|
||||||
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
data['captcha_token'] = captchaTk;
|
||||||
|
data['name'] = usernameController.text;
|
||||||
|
data['nick'] = nicknameController.text;
|
||||||
|
data['email'] = emailController.text;
|
||||||
|
data['password'] = passwordController.text;
|
||||||
|
data['language'] =
|
||||||
|
kServerSupportedLanguages[EasyLocalization.of(
|
||||||
|
context,
|
||||||
|
)!.currentLocale.toString()] ??
|
||||||
|
'en-us';
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showLoadingModal(context);
|
showLoadingModal(context);
|
||||||
final client = ref.watch(apiClientProvider);
|
final client = ref.watch(apiClientProvider);
|
||||||
await client.post(
|
final resp = await client.post(endpoint, data: data);
|
||||||
'/pass/accounts',
|
if (endpoint == '/pass/account/onboard') {
|
||||||
data: {
|
// Onboard response has tokens, set them
|
||||||
'name': usernameController.text,
|
final token = resp.data['token'];
|
||||||
'nick': nicknameController.text,
|
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||||
'email': emailController.text,
|
ref.invalidate(tokenProvider);
|
||||||
'password': passwordController.text,
|
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||||
'language':
|
await userNotifier.fetchUser();
|
||||||
kServerSupportedLanguages[EasyLocalization.of(
|
final apiClient = ref.read(apiClientProvider);
|
||||||
context,
|
subscribePushNotification(apiClient);
|
||||||
)!.currentLocale.toString()] ??
|
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||||
'en-us',
|
wsNotifier.connect();
|
||||||
'captcha_token': captchaTk,
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
},
|
} else {
|
||||||
);
|
if (!context.mounted) return;
|
||||||
if (!context.mounted) return;
|
hideLoadingModal(context);
|
||||||
hideLoadingModal(context);
|
onboardingToken.value = null; // reset
|
||||||
showPostCreateModal();
|
showPostCreateModal();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (context.mounted) hideLoadingModal(context);
|
if (context.mounted) hideLoadingModal(context);
|
||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
|
if (!waitingForOidc.value || !context.mounted) return;
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
// Exchange code for tokens
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/token',
|
||||||
|
data: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': event.challengeId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = resp.data;
|
||||||
|
if (data.containsKey('onboarding_token')) {
|
||||||
|
// New user onboarding
|
||||||
|
final token = data['onboarding_token'] as String;
|
||||||
|
final decoded = decodeJwt(token);
|
||||||
|
final name = decoded['name'] as String?;
|
||||||
|
final email = decoded['email'] as String?;
|
||||||
|
final provider = decoded['provider'] as String?;
|
||||||
|
// Pre-fill form
|
||||||
|
usernameController.text = '';
|
||||||
|
nicknameController.text = name ?? '';
|
||||||
|
emailController.text = email ?? '';
|
||||||
|
passwordController.clear(); // User needs to set password
|
||||||
|
onboardingToken.value = token;
|
||||||
|
// Optionally show a message
|
||||||
|
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
|
||||||
|
} else {
|
||||||
|
// Existing user, switch to login
|
||||||
|
showSnackBar('Account already exists. Redirecting to login.');
|
||||||
|
context.goNamed('login');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return subscription.cancel;
|
||||||
|
}, [waitingForOidc.value, context.mounted]);
|
||||||
|
|
||||||
|
Future<void> withOidc(String provider) async {
|
||||||
|
waitingForOidc.value = true;
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
final url =
|
||||||
|
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
|
||||||
|
.replace(
|
||||||
|
queryParameters: {'redirect_uri': 'solian://auth/callback'},
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
|
final isLaunched = await launchUrlString(
|
||||||
|
url,
|
||||||
|
mode:
|
||||||
|
kIsWeb
|
||||||
|
? LaunchMode.platformDefault
|
||||||
|
: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!isLaunched) {
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return StyledWidget(
|
return StyledWidget(
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
@@ -90,6 +232,48 @@ class CreateAccountContent extends HookConsumerWidget {
|
|||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
).tr().padding(left: 4, bottom: 16),
|
).tr().padding(left: 4, bottom: 16),
|
||||||
|
if (!kIsWeb)
|
||||||
|
Row(
|
||||||
|
spacing: 6,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
|
||||||
|
const Gap(8),
|
||||||
|
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(
|
||||||
|
onPressed: () => withOidc('apple'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"apple",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Apple Account',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 8, vertical: 8)
|
||||||
|
else
|
||||||
|
const Gap(12),
|
||||||
Form(
|
Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
|||||||
@@ -15,7 +15,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/event_bus.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';
|
||||||
@@ -503,12 +503,42 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isBusy = useState(false);
|
final isBusy = useState(false);
|
||||||
final usernameController = useTextEditingController();
|
final usernameController = useTextEditingController();
|
||||||
|
final waitingForOidc = useState(false);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
onBusy.call(isBusy.value);
|
onBusy.call(isBusy.value);
|
||||||
return null;
|
return null;
|
||||||
}, [isBusy]);
|
}, [isBusy]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
|
if (!waitingForOidc.value || !context.mounted) return;
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final resp = await client.get(
|
||||||
|
'/pass/auth/challenge/${event.challengeId}',
|
||||||
|
);
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return subscription.cancel;
|
||||||
|
}, [waitingForOidc.value, context.mounted]);
|
||||||
|
|
||||||
Future<void> requestResetPassword() async {
|
Future<void> requestResetPassword() async {
|
||||||
final uname = usernameController.value.text;
|
final uname = usernameController.value.text;
|
||||||
if (uname.isEmpty) {
|
if (uname.isEmpty) {
|
||||||
@@ -618,28 +648,33 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> withOidc(String provider) async {
|
Future<void> withOidc(String provider) async {
|
||||||
final challengeId = await Navigator.of(context, rootNavigator: true).push(
|
waitingForOidc.value = true;
|
||||||
MaterialPageRoute(
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
|
final token = ref.watch(tokenProvider);
|
||||||
),
|
final deviceId = await getUdid();
|
||||||
|
final queryParams = <String, String>{
|
||||||
|
'redirect_uri': 'solian://auth/callback',
|
||||||
|
'device_id': deviceId,
|
||||||
|
};
|
||||||
|
if (token?.token != null) {
|
||||||
|
queryParams['token'] = token!.token;
|
||||||
|
}
|
||||||
|
final url =
|
||||||
|
Uri.parse(
|
||||||
|
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
|
||||||
|
).replace(queryParameters: queryParams).toString();
|
||||||
|
final isLaunched = await launchUrlString(
|
||||||
|
url,
|
||||||
|
mode:
|
||||||
|
kIsWeb
|
||||||
|
? LaunchMode.platformDefault
|
||||||
|
: LaunchMode.externalApplication,
|
||||||
|
webOnlyWindowName:
|
||||||
|
token?.token != null ? 'auth-${token!.token}' : 'auth',
|
||||||
);
|
);
|
||||||
|
if (!isLaunched) {
|
||||||
final client = ref.watch(apiClientProvider);
|
waitingForOidc.value = false;
|
||||||
try {
|
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||||
final resp = await client.get('/pass/auth/challenge/$challengeId');
|
|
||||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(challenge);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${challenge.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ class PostCreatedEvent {
|
|||||||
class ChatRoomsRefreshEvent {
|
class ChatRoomsRefreshEvent {
|
||||||
const ChatRoomsRefreshEvent();
|
const ChatRoomsRefreshEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event fired when OIDC auth callback is received
|
||||||
|
class OidcAuthCallbackEvent {
|
||||||
|
final String challengeId;
|
||||||
|
|
||||||
|
const OidcAuthCallbackEvent(this.challengeId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:island/pods/activity/activity_rpc.dart';
|
|||||||
import 'package:island/pods/websocket.dart';
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/route.dart';
|
import 'package:island/route.dart';
|
||||||
import 'package:island/screens/tray_manager.dart';
|
import 'package:island/screens/tray_manager.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
import 'package:island/services/notify.dart';
|
import 'package:island/services/notify.dart';
|
||||||
import 'package:island/services/sharing_intent.dart';
|
import 'package:island/services/sharing_intent.dart';
|
||||||
import 'package:island/services/update_service.dart';
|
import 'package:island/services/update_service.dart';
|
||||||
@@ -115,6 +116,18 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleDeepLink(Uri uri, WidgetRef ref) {
|
void _handleDeepLink(Uri uri, WidgetRef ref) {
|
||||||
|
// Special handling for OIDC auth callback
|
||||||
|
if (uri.path == '/auth/callback' &&
|
||||||
|
uri.queryParameters.containsKey('challenge')) {
|
||||||
|
final challenge = uri.queryParameters['challenge']!;
|
||||||
|
eventBus.fire(OidcAuthCallbackEvent(challenge));
|
||||||
|
if (!kIsWeb &&
|
||||||
|
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||||
|
windowManager.show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final router = ref.read(routerProvider);
|
final router = ref.read(routerProvider);
|
||||||
String path = '/${uri.host}${uri.path}';
|
String path = '/${uri.host}${uri.path}';
|
||||||
if (uri.queryParameters.isNotEmpty) {
|
if (uri.queryParameters.isNotEmpty) {
|
||||||
|
|||||||
Reference in New Issue
Block a user