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:
Henry Hiles 2026-07-20 19:46:18 -04:00 committed by Henry Hiles
commit 7d106ad3a2
86 changed files with 558 additions and 1344 deletions

View 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);
}

View file

@ -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 {

View 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,
);
}

View file

@ -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);

View file

@ -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

View 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);
}

View file

@ -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,

View file

@ -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);
}

View 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);
}

View 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 }

View 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,
}

View file

@ -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(

View file

@ -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"),
),
],
),
);
}
}

View file

@ -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;
}