forked from Nexus/nexus
OAuth Support (#53)
Rips out the old auth method, adds OAuth support. Fixes #51. Tested Platforms: - [x] Linux - [x] Flatpak - [x] Native (Nix) - [x] Android - [ ] Windows - [ ] MacOS Reviewed-on: Nexus/nexus#53
This commit is contained in:
parent
5df975f607
commit
7d106ad3a2
86 changed files with 558 additions and 1344 deletions
32
lib/controllers/auth_url.dart
Normal file
32
lib/controllers/auth_url.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_id.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
||||
|
||||
class AuthUrlController extends AsyncNotifier<OAuthAuthCodeResponse> {
|
||||
final Uri homeserver;
|
||||
AuthUrlController(this.homeserver);
|
||||
|
||||
@override
|
||||
Future<OAuthAuthCodeResponse> build() async => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.getAuthUrl(
|
||||
.new(
|
||||
homeserverUrl: homeserver,
|
||||
redirectUri: .new(scheme: "nexus.federated.nexus", path: "/"),
|
||||
responseMode: .query,
|
||||
scopes: .new([Scope.clientApi, Scope.device]),
|
||||
clientId: await ref.watch(
|
||||
ClientIdController.provider(homeserver).future,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.family<
|
||||
AuthUrlController,
|
||||
OAuthAuthCodeResponse,
|
||||
Uri
|
||||
>(AuthUrlController.new);
|
||||
}
|
||||
|
|
@ -15,13 +15,16 @@ import "package:nexus/controllers/top_level_spaces.dart";
|
|||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/models/paginate.dart";
|
||||
import "package:nexus/models/requests/get_event.dart";
|
||||
import "package:nexus/models/requests/get_related_events.dart";
|
||||
import "package:nexus/models/requests/get_room_state.dart";
|
||||
import "package:nexus/models/requests/join_room.dart";
|
||||
import "package:nexus/models/requests/login.dart";
|
||||
import "package:nexus/models/profile.dart";
|
||||
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
||||
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
||||
import "package:nexus/models/requests/oauth/register_client.dart";
|
||||
import "package:nexus/models/requests/paginate.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
import "package:nexus/models/requests/report.dart";
|
||||
|
|
@ -263,6 +266,8 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> setMembership(SetMembershipRequest request) =>
|
||||
_sendCommand("set_membership", request.toJson());
|
||||
|
||||
Future<void> logout() => _sendCommand("logout");
|
||||
|
||||
Future<void> markRead(Room room) async {
|
||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||
final event = eventRowId == null ? null : room.events[eventRowId];
|
||||
|
|
@ -275,14 +280,19 @@ class ClientController extends AsyncNotifier<int> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<String?> login(LoginRequest login) async {
|
||||
try {
|
||||
await _sendCommand("login", login.toJson());
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
Future<String> registerClient(OAuthRegisterClientRequest request) async =>
|
||||
(await _sendCommand(
|
||||
"oauth_register_client",
|
||||
request.toJson(),
|
||||
))["client_id"];
|
||||
|
||||
Future<OAuthAuthCodeResponse> getAuthUrl(OAuthGetAuthUrl request) async =>
|
||||
.fromJson(
|
||||
await _sendCommand("oauth_get_authorization_url", request.toJson()),
|
||||
);
|
||||
|
||||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
||||
|
||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||
try {
|
||||
|
|
|
|||
33
lib/controllers/client_id.dart
Normal file
33
lib/controllers/client_id.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
||||
class ClientIdController extends AsyncNotifier<String> {
|
||||
final Uri homeserver;
|
||||
ClientIdController(this.homeserver);
|
||||
|
||||
@override
|
||||
Future<String> build() => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.registerClient(
|
||||
.new(
|
||||
clientName: "Nexus",
|
||||
applicationType: .native,
|
||||
grantTypes: .new([.authorizationCode, .refreshToken]),
|
||||
responseTypes: .new([.code]),
|
||||
logoUri: Uri.https(
|
||||
"nexus.federated.nexus",
|
||||
"raw/branch/main/assets/mobile.svg",
|
||||
),
|
||||
homeserverUrl: homeserver,
|
||||
clientUri: Uri.https("nexus.federated.nexus"),
|
||||
redirectUris: .new([
|
||||
.new(scheme: "nexus.federated.nexus", path: "/"),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.family<ClientIdController, String, Uri>(
|
||||
ClientIdController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
|
@ -87,11 +89,46 @@ class SettingsSectionsController
|
|||
]),
|
||||
),
|
||||
]),
|
||||
"Account": .new([
|
||||
.new(title: "Profile", icon: Icons.person, settings: .new([])),
|
||||
.new(
|
||||
title: "Other",
|
||||
icon: Icons.key,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Log Out",
|
||||
description:
|
||||
"Log out of your account, returning you to the login page.",
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return M3EButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.logout();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
label: Text(title),
|
||||
icon: Icon(icon),
|
||||
tooltip: description,
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
icon: Icons.logout,
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
SettingsSectionsController,
|
||||
IMap<String, IList<SettingsCategory>>
|
||||
>(SettingsSectionsController.new);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ void main() async {
|
|||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
retry: null,
|
||||
observers: [
|
||||
// Change false to true if you want debug information on provider reloads
|
||||
// ignore: dead_code
|
||||
|
|
|
|||
15
lib/models/oauth_auth_code_response.dart
Normal file
15
lib/models/oauth_auth_code_response.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "oauth_auth_code_response.freezed.dart";
|
||||
part "oauth_auth_code_response.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class OAuthAuthCodeResponse with _$OAuthAuthCodeResponse {
|
||||
const factory OAuthAuthCodeResponse({
|
||||
required String state,
|
||||
required String codeVerifier,
|
||||
required Uri url,
|
||||
}) = _OAuthAuthCodeResponse;
|
||||
|
||||
factory OAuthAuthCodeResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$OAuthAuthCodeResponseFromJson(json);
|
||||
}
|
||||
|
|
@ -2,9 +2,8 @@ import "package:freezed_annotation/freezed_annotation.dart";
|
|||
part "get_event.freezed.dart";
|
||||
part "get_event.g.dart";
|
||||
|
||||
@Freezed()
|
||||
@freezed
|
||||
abstract class GetEventRequest with _$GetEventRequest {
|
||||
const GetEventRequest._();
|
||||
const factory GetEventRequest({
|
||||
required String roomId,
|
||||
required String eventId,
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "login.freezed.dart";
|
||||
part "login.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class LoginRequest with _$LoginRequest {
|
||||
const factory LoginRequest({
|
||||
required String username,
|
||||
required String password,
|
||||
required String homeserverUrl,
|
||||
}) = _LoginRequest;
|
||||
|
||||
factory LoginRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$LoginRequestFromJson(json);
|
||||
}
|
||||
17
lib/models/requests/oauth/exchange_token.dart
Normal file
17
lib/models/requests/oauth/exchange_token.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "exchange_token.freezed.dart";
|
||||
part "exchange_token.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class OAuthExchangeTokenRequest with _$OAuthExchangeTokenRequest {
|
||||
const factory OAuthExchangeTokenRequest({
|
||||
required Uri homeserverUrl,
|
||||
required String codeVerifier,
|
||||
required Uri redirectUri,
|
||||
required String code,
|
||||
required String clientId,
|
||||
}) = _OAuthExchangeTokenRequest;
|
||||
|
||||
factory OAuthExchangeTokenRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$OAuthExchangeTokenRequestFromJson(json);
|
||||
}
|
||||
43
lib/models/requests/oauth/get_auth_url.dart
Normal file
43
lib/models/requests/oauth/get_auth_url.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "get_auth_url.freezed.dart";
|
||||
part "get_auth_url.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class OAuthGetAuthUrl with _$OAuthGetAuthUrl {
|
||||
const factory OAuthGetAuthUrl({
|
||||
required ResponseMode responseMode,
|
||||
required Uri homeserverUrl,
|
||||
required Uri redirectUri,
|
||||
required IList<String> scopes,
|
||||
required String clientId,
|
||||
String? userIdHint,
|
||||
}) = _OAuthGetAuthUrl;
|
||||
|
||||
factory OAuthGetAuthUrl.fromJson(Map<String, Object?> json) =>
|
||||
_$OAuthGetAuthUrlFromJson(json);
|
||||
}
|
||||
|
||||
abstract class Scope {
|
||||
static final openid = "openid";
|
||||
static final email = "email";
|
||||
static final clientApi = "urn:matrix:client:api:*";
|
||||
|
||||
static final _deviceChars = IList(
|
||||
("ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789"
|
||||
"-._~")
|
||||
.split(""),
|
||||
);
|
||||
|
||||
static String get _deviceId =>
|
||||
_deviceChars.shuffle(Random.secure()).sublist(0, 10).join();
|
||||
|
||||
static String get device => "urn:matrix:client:device:$_deviceId";
|
||||
}
|
||||
|
||||
@JsonEnum(fieldRename: .snake)
|
||||
enum ResponseMode { query, fragment }
|
||||
47
lib/models/requests/oauth/register_client.dart
Normal file
47
lib/models/requests/oauth/register_client.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "register_client.freezed.dart";
|
||||
part "register_client.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class OAuthRegisterClientRequest with _$OAuthRegisterClientRequest {
|
||||
const factory OAuthRegisterClientRequest({
|
||||
required Uri homeserverUrl,
|
||||
@Default(ApplicationType.web) ApplicationType applicationType,
|
||||
String? clientName,
|
||||
required Uri clientUri,
|
||||
Uri? logoUri,
|
||||
Uri? policyUri,
|
||||
Uri? tosUri,
|
||||
IList<GrantType>? grantTypes,
|
||||
IList<Uri>? redirectUris,
|
||||
IList<ResponseType>? responseTypes,
|
||||
AuthMethod? authMethod,
|
||||
}) = _OAuthRegisterClientRequest;
|
||||
|
||||
factory OAuthRegisterClientRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$OAuthRegisterClientRequestFromJson(json);
|
||||
}
|
||||
|
||||
enum ApplicationType { native, web }
|
||||
|
||||
@JsonEnum(fieldRename: .snake)
|
||||
enum ResponseType { code, idToken }
|
||||
|
||||
@JsonEnum(fieldRename: .snake)
|
||||
enum AuthMethod {
|
||||
clientSecretPost,
|
||||
clientSecretBasic,
|
||||
clientSecretJwt,
|
||||
privateKeyJwt,
|
||||
none,
|
||||
}
|
||||
|
||||
@JsonEnum(fieldRename: .snake)
|
||||
enum GrantType {
|
||||
authorizationCode,
|
||||
refreshToken,
|
||||
clientCredentials,
|
||||
@JsonValue("urn:ietf:params:oauth:grant-type:device_code")
|
||||
deviceCode,
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ class ChatPage extends ConsumerWidget {
|
|||
final showMembersByDefault = constraints.maxWidth > 1000;
|
||||
final initComplete = ref.watch(InitCompleteController.provider);
|
||||
final roomId = ref.watch(KeyController.provider(KeyController.roomKey));
|
||||
|
||||
ref.read(EmojiController.provider);
|
||||
|
||||
return SafeArea(
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/helpers/required_validator_helper.dart";
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
final Uri homeserver;
|
||||
const LoginPage({super.key, required this.homeserver});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
|
||||
final isLoading = useState(false);
|
||||
final username = useTextEditingController();
|
||||
final password = useTextEditingController();
|
||||
|
||||
final inputError = useState<String?>(null);
|
||||
final formKey = useRef(GlobalKey<FormState>());
|
||||
|
||||
Future<void> tryLogin() async {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
if (formKey.value.currentState?.validate() != true) return;
|
||||
|
||||
final error = await client.login(
|
||||
.new(
|
||||
username: username.text,
|
||||
password: password.text,
|
||||
homeserverUrl: homeserver.origin,
|
||||
),
|
||||
);
|
||||
|
||||
if (error != null) {
|
||||
inputError.value = error;
|
||||
isLoading.value = false;
|
||||
} else {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
),
|
||||
body: AlertDialog(
|
||||
title: Text("Login to ${homeserver.host}"),
|
||||
content: Form(
|
||||
key: formKey.value,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
textInputAction: .next,
|
||||
autovalidateMode: .onUserInteraction,
|
||||
validator: requiredValidator,
|
||||
decoration: .new(label: Text("Username")),
|
||||
controller: username,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextFormField(
|
||||
textInputAction: .done,
|
||||
decoration: .new(
|
||||
label: Text("Password"),
|
||||
errorText: inputError.value,
|
||||
errorMaxLines: 5,
|
||||
),
|
||||
autovalidateMode: .onUserInteraction,
|
||||
validator: requiredValidator,
|
||||
controller: password,
|
||||
obscureText: true,
|
||||
onFieldSubmitted: (_) => tryLogin(),
|
||||
// Don't defocus on submit
|
||||
onEditingComplete: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: isLoading.value ? null : tryLogin,
|
||||
child: Text("Sign In"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import "package:app_links/app_links.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_svg/flutter_svg.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/auth_url.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_id.dart";
|
||||
import "package:nexus/helpers/launch_helper.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/homeserver.dart";
|
||||
import "package:nexus/pages/login.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
|
||||
|
|
@ -32,7 +35,7 @@ class SelectServerPage extends HookConsumerWidget {
|
|||
final newUrl = newHomeserver == null
|
||||
? null
|
||||
: await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.discoverHomeserver(newHomeserver);
|
||||
|
||||
if (context.mounted) {
|
||||
|
|
@ -47,11 +50,42 @@ class SelectServerPage extends HookConsumerWidget {
|
|||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)),
|
||||
final codeResponse = await ref.watch(
|
||||
AuthUrlController.provider(newUrl).future,
|
||||
);
|
||||
|
||||
await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url);
|
||||
|
||||
AppLinks().uriLinkStream.listen((encodedUri) async {
|
||||
final code = encodedUri.queryParameters["code"];
|
||||
|
||||
if (code != null) {
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.exchangeToken(
|
||||
.new(
|
||||
homeserverUrl: newUrl,
|
||||
codeVerifier: codeResponse.codeVerifier,
|
||||
redirectUri: .new(
|
||||
scheme: "nexus.federated.nexus",
|
||||
path: "/",
|
||||
),
|
||||
code: code,
|
||||
clientId: await ref.watch(
|
||||
ClientIdController.provider(newUrl).future,
|
||||
),
|
||||
),
|
||||
)
|
||||
.onError(showError);
|
||||
}
|
||||
});
|
||||
// await Navigator.of(context).push(
|
||||
// MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)),
|
||||
// );
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue