Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions mobile/lib/models/auth/oauth_login_data.model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class OAuthLoginData {
final String serverUrl;
final String state;
final String codeVerifier;

const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
}
2 changes: 1 addition & 1 deletion mobile/lib/pages/login/login.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
});

return Scaffold(
body: LoginForm(),
body: const LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
Expand Down
40 changes: 40 additions & 0 deletions mobile/lib/providers/auth.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';

class ServerAuthSettings {
final String endpoint;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonText;

const ServerAuthSettings({
required this.endpoint,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonText,
});
}

final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
Expand All @@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
ref.watch(serverInfoServiceProvider),
);
});

Expand All @@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
final UploadService _uploadService;
final SecureStorageService _secureStorageService;
final WidgetService _widgetService;
final ServerInfoService _serverInfoService;
final _log = Logger("AuthenticationNotifier");

static const Duration _timeoutDuration = Duration(seconds: 7);
Expand All @@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._uploadService,
this._secureStorageService,
this._widgetService,
this._serverInfoService,
) : super(
const AuthState(
deviceId: "",
Expand All @@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _authService.validateServerUrl(url);
}

Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
final sanitizedUrl = sanitizeUrl(serverUrl);
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);

final endpoint = await _authService.validateServerUrl(encodedUrl);

final features = await _serverInfoService.getServerFeatures();
final config = await _serverInfoService.getServerConfig();

if (features == null || config == null) {
return null;
}

return ServerAuthSettings(
endpoint: endpoint,
isOAuthEnabled: features.oauthEnabled,
isPasswordLoginEnabled: features.passwordLogin,
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
);
}
Comment on lines +99 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have ServerInfoNotifier that fetches and stores all the above params, are we moving away from using it?


/// Validating the url is the alternative connecting server url without
/// saving the information to the local database
Future<bool> validateAuxilaryServerUrl(String url) async {
Expand Down
24 changes: 23 additions & 1 deletion mobile/lib/providers/oauth.provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:openapi/api.dart';

export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';

final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));

final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
);

class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
final OAuthService _oAuthService;

OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));

Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
return _oAuthService.getOAuthLoginData(serverUrl);
}

Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return _oAuthService.completeOAuthLogin(oAuthData);
}
}
Comment on lines +15 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels redundant. We can access and use the oAuthServiceProvider provider directly

50 changes: 50 additions & 0 deletions mobile/lib/services/oauth.service.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import 'dart:convert';
import 'dart:math';

import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';

Expand All @@ -11,6 +17,50 @@ class OAuthService {
final log = Logger('OAuthService');
OAuthService(this._apiService);

String _generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}

List<int> _randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}

/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String _randomCodeVerifier() {
return base64Url.encode(_randomBytes(42));
}

String _generatePKCECodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}

/// Initiates OAuth login flow.
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
final state = _generateRandomString(32);
final codeVerifier = _randomCodeVerifier();
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);

final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);

if (oAuthServerUrl == null) {
return null;
}

return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
}

Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
}

Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
Expand Down
7 changes: 3 additions & 4 deletions mobile/lib/widgets/forms/login/login_button.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class LoginButton extends ConsumerWidget {
final Function() onPressed;
class LoginButton extends StatelessWidget {
final VoidCallback onPressed;

const LoginButton({super.key, required this.onPressed});

@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: onPressed,
Expand Down
95 changes: 95 additions & 0 deletions mobile/lib/widgets/forms/login/login_credentials_form.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';

class LoginCredentialsForm extends StatelessWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
final FocusNode emailFocusNode;
final FocusNode passwordFocusNode;
final bool isLoading;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonLabel;
final String? warningMessage;
final VoidCallback onLogin;
final VoidCallback onOAuthLogin;
final VoidCallback onBack;

const LoginCredentialsForm({
super.key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
required this.emailFocusNode,
required this.passwordFocusNode,
required this.isLoading,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonLabel,
required this.warningMessage,
required this.onLogin,
required this.onOAuthLogin,
required this.onBack,
});

@override
Widget build(BuildContext context) {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnabled) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
],
isLoading
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
if (isOAuthEnabled) ...[
if (isPasswordLoginEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel,
onPressed: onOAuthLogin,
),
],
],
),
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
],
),
);
}
}
Loading
Loading