Skip to content

Commit

Permalink
Enable picking between concrexit servers (#459)
Browse files Browse the repository at this point in the history
  • Loading branch information
DeD1rk authored and JAicewizard committed Sep 20, 2023
1 parent b0a4068 commit a0404ab
Show file tree
Hide file tree
Showing 30 changed files with 652 additions and 377 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ jobs:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_PRIVATE_KEY: ${{ secrets.PASSWORDS_REPO_DEPLOY_KEY }}
THALIA_API_HOST: ${{ vars.THALIA_API_HOST }}
THALIA_OAUTH_APP_ID: ${{ secrets.THALIA_OAUTH_APP_ID }}
THALIA_OAUTH_APP_SECRET: ${{ secrets.THALIA_OAUTH_APP_SECRET }}
TOSTI_API_HOST: ${{ vars.TOSTI_API_HOST }}
Expand Down Expand Up @@ -198,7 +197,6 @@ jobs:
- name: Build app
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
THALIA_API_HOST: ${{ vars.THALIA_API_HOST }}
THALIA_OAUTH_APP_ID: ${{ secrets.THALIA_OAUTH_APP_ID }}
THALIA_OAUTH_APP_SECRET: ${{ secrets.THALIA_OAUTH_APP_SECRET }}
TOSTI_API_HOST: ${{ vars.TOSTI_API_HOST }}
Expand Down
3 changes: 1 addition & 2 deletions fastlane/Fastfile-Android
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ platform :android do
build_cmd += " appbundle"
end

if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] and ENV["THALIA_API_HOST"]
build_cmd += " --dart-define=THALIA_API_HOST=\"#{ENV["THALIA_API_HOST"]}\""
if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"]
build_cmd += " --dart-define=THALIA_OAUTH_APP_ID=\"#{ENV["THALIA_OAUTH_APP_ID"]}\""
build_cmd += " --dart-define=THALIA_OAUTH_APP_SECRET=\"#{ENV["THALIA_OAUTH_APP_SECRET"]}\""
end
Expand Down
3 changes: 1 addition & 2 deletions fastlane/Fastfile-iOS
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ platform :ios do
sh("flutter clean")
build_cmd = "flutter build ios --release --config-only"

if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] and ENV["THALIA_API_HOST"]
build_cmd += " --dart-define=THALIA_API_HOST=\"#{ENV["THALIA_API_HOST"]}\""
if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"]
build_cmd += " --dart-define=THALIA_OAUTH_APP_ID=\"#{ENV["THALIA_OAUTH_APP_ID"]}\""
build_cmd += " --dart-define=THALIA_OAUTH_APP_SECRET=\"#{ENV["THALIA_OAUTH_APP_SECRET"]}\""
end
Expand Down
4 changes: 2 additions & 2 deletions integration_test/login_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ void testLogin() {
when(authCubit.state).thenReturn(state);
})
..add(LoadingAuthState())
..add(LoggedOutAuthState());
..add(const LoggedOutAuthState());

when(authCubit.load()).thenAnswer((_) => Future.value(null));
when(authCubit.stream).thenAnswer((_) => streamController.stream);
Expand Down Expand Up @@ -138,7 +138,7 @@ void testLogin() {

verify(authCubit.logOut()).called(1);

streamController.add(LoggedOutAuthState());
streamController.add(LoggedOutAuthState(apiRepository: api));
await tester.pumpAndSettle();

expect(find.text('LOGIN'), findsOneWidget);
Expand Down
5 changes: 4 additions & 1 deletion lib/api/api_repository.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:reaxit/config.dart';
import 'package:reaxit/models.dart';
import 'package:reaxit/api/exceptions.dart';

Expand All @@ -7,7 +8,9 @@ import 'package:reaxit/api/exceptions.dart';
/// In case credentials cannot be refreshed, this calls `logOut`, which should
/// close the client and indicates that the user is no longer logged in.
abstract class ApiRepository {
ApiRepository();
final Config config;

ApiRepository(this.config);

/// Closes the connection to the api. This must be called when logging out.
void close();
Expand Down
48 changes: 35 additions & 13 deletions lib/api/concrexit_api_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:http_parser/http_parser.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:reaxit/api/api_repository.dart';
import 'package:reaxit/api/exceptions.dart';
import 'package:reaxit/config.dart' as config;
import 'package:reaxit/config.dart';
import 'package:reaxit/models.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

Expand Down Expand Up @@ -50,29 +50,51 @@ class LoggingClient extends oauth2.Client {
/// In case credentials cannot be refreshed, this calls `logOut`, which should
/// close the client and indicates that the user is no longer logged in.
class ConcrexitApiRepository implements ApiRepository {
/// The [oauth2.Client] used to access the API.
final LoggingClient _client;
@override
final Config config;

/// The authenticated client used to access the API.
LoggingClient? _innerClient;

final Function() _onLogOut;

ConcrexitApiRepository({
/// The [oauth2.Client] used to access the API.
/// The authenticated client used to access the API.
required LoggingClient client,

/// An [Config] describing the API.
required this.config,

/// Called when the client can no longer authenticate.
required Function() onLogOut,
}) : _client = client,
_onLogOut = onLogOut;
}) : _innerClient = client,
_onLogOut = onLogOut,
_baseUri = Uri(
scheme: config.scheme,
host: config.host,
port: config.port,
);

@override
void close() {
_client.close();
if (_innerClient != null) {
_innerClient!.close();
_innerClient = null;
}
}

/// The authenticated client used to access the API.
///
/// Throws [ApiException.notLoggedIn] if the ApiRepository is not closed.
LoggingClient get _client {
if (_innerClient == null) {
throw ApiException.notLoggedIn;
} else {
return _innerClient!;
}
}

static final Uri _baseUri = Uri(
scheme: config.apiScheme,
host: config.apiHost,
port: config.apiPort,
);
final Uri _baseUri;

static const String _basePath = 'api/v2';

Expand All @@ -82,7 +104,7 @@ class ConcrexitApiRepository implements ApiRepository {
};

/// Convenience method for building a URL to an API endpoint.
static Uri _uri({required String path, Map<String, dynamic>? query}) {
Uri _uri({required String path, Map<String, dynamic>? query}) {
return _baseUri.replace(
path: path.startsWith('/') ? '$_basePath$path' : '$_basePath/$path',
queryParameters: query,
Expand Down
4 changes: 2 additions & 2 deletions lib/blocs/album_list_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:reaxit/api/api_repository.dart';
import 'package:reaxit/api/exceptions.dart';
import 'package:reaxit/config.dart' as config;
import 'package:reaxit/config.dart';
import 'package:reaxit/blocs.dart';
import 'package:reaxit/models.dart';

Expand Down Expand Up @@ -112,7 +112,7 @@ class AlbumListCubit extends Cubit<AlbumListState> {
/// Don't get results when the query is empty.
emit(const AlbumListState.loading(results: []));
} else {
_searchDebounceTimer = Timer(config.searchDebounceTime, load);
_searchDebounceTimer = Timer(Config.searchDebounceTime, load);
}
}
}
Expand Down
109 changes: 78 additions & 31 deletions lib/blocs/auth_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,28 @@ import 'package:oauth2/oauth2.dart';
import 'package:reaxit/api/api_repository.dart';
import 'package:reaxit/api/concrexit_api_repository.dart';
import 'package:reaxit/api/exceptions.dart';
import 'package:reaxit/config.dart' as config;
import 'package:reaxit/config.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';

final _redirectUrl = Uri.parse(
'nu.thalia://callback',
);

final Uri _authorizationEndpoint = Uri(
scheme: config.apiScheme,
host: config.apiHost,
port: config.apiPort,
path: 'user/oauth/authorize/',
);

final Uri _tokenEndpoint = Uri(
scheme: config.apiScheme,
host: config.apiHost,
port: config.apiPort,
path: 'user/oauth/token/',
);

const _credentialsStorageKey = 'ThaliApp OAuth2 credentials';

const _devicePkPreferenceKey = 'deviceRegistrationId';

abstract class AuthState extends Equatable {
enum Environment {
staging,
production,
local;

static const defaultEnvironment =
Config.production != null ? Environment.production : Environment.staging;
}

class AuthState extends Equatable {
const AuthState();

@override
Expand All @@ -49,7 +44,21 @@ abstract class AuthState extends Equatable {
class LoadingAuthState extends AuthState {}

/// Not logged in.
class LoggedOutAuthState extends AuthState {}
class LoggedOutAuthState extends AuthState {
final Environment selectedEnvironment;

/// A closed [ApiRepository] left over after logging out, that allows
/// keeping cubits alive while animating towards the login screen.
final ApiRepository? apiRepository;

const LoggedOutAuthState({
this.selectedEnvironment = Environment.defaultEnvironment,
this.apiRepository,
});

@override
List<Object?> get props => [selectedEnvironment, apiRepository];
}

/// Logged in.
class LoggedInAuthState extends AuthState {
Expand Down Expand Up @@ -94,26 +103,38 @@ class AuthCubit extends Cubit<AuthState> {
// Retrieve existing credentials.
final stored = await storage.read(
key: _credentialsStorageKey,
iOptions:
const IOSOptions(accessibility: KeychainAccessibility.first_unlock),
iOptions: const IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);

if (stored != null) {
// Restore credentials from the storage.
final credentials = Credentials.fromJson(stored);

final Config apiConfig;
if (Config.production != null &&
credentials.tokenEndpoint == Config.production!.tokenEndpoint) {
apiConfig = Config.production!;
} else if (Config.local != null &&
credentials.tokenEndpoint == Config.local!.tokenEndpoint) {
apiConfig = Config.local!;
} else {
apiConfig = Config.staging;
}

// Log out if not all required scopes are available. After an update that
// introduces a new scope, this will cause the app to log out and get new
// credentials with the required scopes, instead of just getting 403's
// until you manually log out.
final scopes = credentials.scopes?.toSet() ?? <String>{};
if (scopes.containsAll(config.oauthScopes)) {
if (scopes.containsAll(Config.oauthScopes)) {
// Create the API repository.
final apiRepository = ConcrexitApiRepository(
client: LoggingClient(
credentials,
identifier: config.apiIdentifier,
secret: config.apiSecret,
identifier: apiConfig.identifier,
secret: apiConfig.secret,
onCredentialsRefreshed: (credentials) async {
const storage = FlutterSecureStorage();
await storage.write(
Expand All @@ -129,6 +150,7 @@ class AuthCubit extends Cubit<AuthState> {
SentryStatusCode.range(405, 499),
]),
),
config: apiConfig,
onLogOut: logOut,
);

Expand All @@ -145,7 +167,7 @@ class AuthCubit extends Cubit<AuthState> {
} else {
// Clear username for sentry.
Sentry.configureScope((scope) => scope.setUser(null));
emit(LoggedOutAuthState());
emit(const LoggedOutAuthState());
}
} on PlatformException {
try {
Expand All @@ -158,7 +180,7 @@ class AuthCubit extends Cubit<AuthState> {
} on PlatformException {
// Ignore.
}
emit(LoggedOutAuthState());
emit(const LoggedOutAuthState());
}
}

Expand All @@ -169,15 +191,21 @@ class AuthCubit extends Cubit<AuthState> {
/// authenticated client to make requests to the API. Furthermore, it will
/// handle whatever need to happen upon loggin in, such as setting up push
/// notifications.
Future<void> logIn() async {
Future<void> logIn(Environment environment) async {
emit(LoadingAuthState());

final apiConfig = switch (environment) {
Environment.staging => Config.staging,
Environment.production => Config.production ?? Config.staging,
Environment.local => Config.local ?? Config.staging,
};

// Prepare for the authentication flow.
final grant = AuthorizationCodeGrant(
config.apiIdentifier,
_authorizationEndpoint,
_tokenEndpoint,
secret: config.apiSecret,
apiConfig.identifier,
apiConfig.authorizationEndpoint,
apiConfig.tokenEndpoint,
secret: apiConfig.secret,
onCredentialsRefreshed: (credentials) async {
// When credentials are refreshed, store them.
const storage = FlutterSecureStorage();
Expand All @@ -193,7 +221,7 @@ class AuthCubit extends Cubit<AuthState> {

final authorizeUrl = grant.getAuthorizationUrl(
_redirectUrl,
scopes: config.oauthScopes,
scopes: Config.oauthScopes,
);

try {
Expand Down Expand Up @@ -222,6 +250,7 @@ class AuthCubit extends Cubit<AuthState> {

final apiRepository = ConcrexitApiRepository(
client: LoggingClient.fromClient(client),
config: apiConfig,
onLogOut: logOut,
);

Expand All @@ -231,12 +260,16 @@ class AuthCubit extends Cubit<AuthState> {
} on PlatformException catch (exception) {
// Forward exceptions from the authentication flow.
emit(FailureAuthState(message: exception.message));
emit(LoggedOutAuthState(selectedEnvironment: environment));
} on SocketException catch (_) {
emit(const FailureAuthState(message: 'No internet.'));
emit(LoggedOutAuthState(selectedEnvironment: environment));
} on AuthorizationException catch (_) {
emit(const FailureAuthState(message: 'Authorization failed.'));
emit(LoggedOutAuthState(selectedEnvironment: environment));
} catch (_) {
emit(const FailureAuthState(message: 'An unknown error occurred.'));
emit(LoggedOutAuthState(selectedEnvironment: environment));
}
}

Expand All @@ -262,7 +295,21 @@ class AuthCubit extends Cubit<AuthState> {

// Clear username for sentry.
Sentry.configureScope((scope) => scope.setUser(null));
emit(LoggedOutAuthState());

if (state is LoggedInAuthState) {
emit(LoggedOutAuthState(apiRepository: state.apiRepository));
}
}

/// Change the selected environment when on the login screen.
void selectEnvironment(Environment environment) {
final state = this.state;
if (state is LoggedOutAuthState) {
emit(LoggedOutAuthState(
selectedEnvironment: environment,
apiRepository: state.apiRepository,
));
}
}

/// Set up push notifications.
Expand Down
Loading

0 comments on commit a0404ab

Please sign in to comment.