Skip to content
Merged
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
33 changes: 11 additions & 22 deletions lib/app/dependencies_provider.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import 'dart:async';

import 'package:cafe_analog_app/core/network_request_executor.dart';
import 'package:cafe_analog_app/core/network_request_interceptor.dart';
import 'package:cafe_analog_app/generated/api/coffeecard_api_v1.swagger.dart'
hide $JsonSerializableConverter;
import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart';
import 'package:cafe_analog_app/http/http.dart';
import 'package:cafe_analog_app/login/bloc/auth_cubit_handle.dart';
import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart';
import 'package:cafe_analog_app/login/data/auth_token_store.dart';
import 'package:cafe_analog_app/login/data/authentication_token_repository.dart';
import 'package:cafe_analog_app/login/data/login_repository.dart';
import 'package:chopper/chopper.dart';
Expand Down Expand Up @@ -34,20 +32,16 @@ class DependenciesProvider extends StatelessWidget {
RepositoryProvider.value(value: localStorage),
RepositoryProvider(create: (_) => const FlutterSecureStorage()),
RepositoryProvider(create: (_) => AuthTokenStore()),

// Http
RepositoryProvider(create: (_) => AuthCubitHandle()),
RepositoryProvider(
create: (context) => ChopperClient(
baseUrl: Uri.parse('https://core.dev.analogio.dk'),
interceptors: [
NetworkRequestInterceptor(authTokenStore: context.read()),
],
converter: $JsonSerializableConverter(),
services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()],
// FIXME(marfavi): Add authenticator to redirect on 401 responses
// authenticator: sl.get<ReactivationAuthenticator>(),
create: (context) => AuthTokenRepository(
secureStorage: context.read(),
authTokenStore: context.read(),
),
),

// Http
RepositoryProvider(create: makeHttpClient),
RepositoryProvider(
create: (context) =>
context.read<ChopperClient>().getService<CoffeecardApiV1>(),
Expand All @@ -69,12 +63,6 @@ class DependenciesProvider extends StatelessWidget {
RepositoryProvider(
create: (context) => LoginRepository(executor: context.read()),
),
RepositoryProvider(
create: (context) => AuthTokenRepository(
secureStorage: context.read(),
authTokenStore: context.read(),
),
),
],
child: MultiBlocProvider(
providers: [
Expand All @@ -85,6 +73,7 @@ class DependenciesProvider extends StatelessWidget {
loginRepository: context.read(),
);
unawaited(authCubit.start());
context.read<AuthCubitHandle>().bind(authCubit);
return authCubit;
},
),
Expand Down
11 changes: 7 additions & 4 deletions lib/core/failures.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ sealed class NetworkFailure extends Failure {
}

class ServerFailure<BodyType> extends NetworkFailure {
const ServerFailure(super.reason, this.statuscode);
const ServerFailure(super.reason, this.statusCode);

factory ServerFailure.fromResponse(Response<BodyType> response) {
try {
Expand All @@ -30,15 +30,18 @@ class ServerFailure<BodyType> extends NetworkFailure {
final message = jsonString['message'] as String?;

return ServerFailure(
message ?? 'An unknown error occured',
'${message ?? 'An unknown error occurred'} (${response.statusCode})',
response.statusCode,
);
} on Exception {
return ServerFailure('An unknown error occured', response.statusCode);
return ServerFailure(
'An unknown error occurred (${response.statusCode})',
response.statusCode,
);
}
}

final int statuscode;
final int statusCode;
}

class ConnectionFailure extends NetworkFailure {
Expand Down
50 changes: 50 additions & 0 deletions lib/core/widgets/app_bar.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:cafe_analog_app/login/data/authentication_token_repository.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class AnalogAppBar extends StatelessWidget implements PreferredSizeWidget {
const AnalogAppBar({
Expand Down Expand Up @@ -34,6 +36,54 @@ class AnalogAppBar extends StatelessWidget implements PreferredSizeWidget {
onBrightnessChanged?.call(newBrightness);
},
),
if (kDebugMode)
IconButton(
icon: const Icon(Icons.construction_rounded),
tooltip: 'Invalidate JWT',
onPressed: () async {
final invalidateRefreshToken = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Invalidate tokens'),
content: const Text(
'Do you want to invalidate only the JWT '
'or also the refresh token?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('JWT only'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('JWT + refresh'),
),
],
),
);

if (invalidateRefreshToken == null) return;
if (!context.mounted) return;

final result = await context
.read<AuthTokenRepository>()
.invalidateJwt(invalidateRefreshToken: invalidateRefreshToken)
.run();

if (!context.mounted) return;

final message = result.match(
(failure) => 'Failed to invalidate JWT: ${failure.reason}',
(_) => invalidateRefreshToken
? 'JWT and refresh token invalidated.'
: 'JWT invalidated (refresh token preserved).',
);

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
},
),
],
);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/http/http.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export 'package:cafe_analog_app/generated/api/client_index.dart';
export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart';
export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart';
export 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart';

export 'make_http_client.dart';
export 'network_request_executor.dart';
export 'network_request_interceptor.dart';
export 'token_refresh_authenticator.dart';
30 changes: 30 additions & 0 deletions lib/http/make_http_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:cafe_analog_app/http/http.dart';
import 'package:chopper/chopper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

const String _baseUrl = 'https://core.dev.analogio.dk';

/// Creates and configures the [ChopperClient]s used for network requests.
ChopperClient makeHttpClient(BuildContext context) {
// Separate http client only used to refresh token requests.
// Calls done from this client won't trigger the authenticator
// (token refresh logic) or interceptor (injecting jwt token in auth header).
final tokenRefreshClient = ChopperClient(
baseUrl: Uri.parse(_baseUrl),
converter: $JsonSerializableConverter(),
services: [CoffeecardApiV2.create()],
);

return ChopperClient(
baseUrl: Uri.parse(_baseUrl),
converter: $JsonSerializableConverter(),
services: [CoffeecardApiV1.create(), CoffeecardApiV2.create()],
interceptors: [NetworkRequestInterceptor(authTokenStore: context.read())],
authenticator: TokenRefreshAuthenticator(
authTokenRepository: context.read(),
tokenRefreshApi: tokenRefreshClient.getService<CoffeecardApiV2>(),
authCubitHandle: context.read(),
),
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:cafe_analog_app/core/failures.dart';
import 'package:cafe_analog_app/generated/api/client_index.dart';
import 'package:cafe_analog_app/http/http.dart';
import 'package:chopper/chopper.dart' show Response;
import 'package:fpdart/fpdart.dart';
import 'package:logger/logger.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import 'dart:async';

import 'package:cafe_analog_app/login/data/auth_token_store.dart';
import 'package:chopper/chopper.dart';

class AuthTokenStore {
String? token;
}

/// An interceptor that adds the Authorization header with the JWT token
/// to outgoing HTTP requests if a token is available in the [AuthTokenStore].
class NetworkRequestInterceptor implements Interceptor {
NetworkRequestInterceptor({
required AuthTokenStore authTokenStore,
Expand Down
123 changes: 123 additions & 0 deletions lib/http/token_refresh_authenticator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:developer';

import 'package:cafe_analog_app/http/http.dart';
import 'package:cafe_analog_app/login/bloc/auth_cubit_handle.dart';
import 'package:cafe_analog_app/login/data/authentication_token_repository.dart';
import 'package:cafe_analog_app/login/data/authentication_tokens.dart';
import 'package:chopper/chopper.dart';

class TokenRefreshAuthenticator extends Authenticator {
TokenRefreshAuthenticator({
required AuthTokenRepository authTokenRepository,
required CoffeecardApiV2 tokenRefreshApi,
required AuthCubitHandle authCubitHandle,
}) : _authTokenRepository = authTokenRepository,
_tokenRefreshApi = tokenRefreshApi,
_authCubitHandle = authCubitHandle;

/// This header is added to requests that have already been retried after a
/// token refresh. Prevents infinite retry loops in case the new token is also
/// invalid for some reason.
static const _retryHeader = 'X-Auth-Retry';

final AuthTokenRepository _authTokenRepository;
final CoffeecardApiV2 _tokenRefreshApi;
final AuthCubitHandle _authCubitHandle;
Completer<AuthTokens?>? _refreshCompleter;

@override
FutureOr<Request?> authenticate(
Request request,
Response<dynamic> response, [
Request? _,
]) async {
if (response.statusCode != 401) return null;
if (request.headers[_retryHeader] == 'true') return null;
if (request.url.path.endsWith('/api/v2/account/auth')) return null;

log(
'Received 401 response for request: ${request.url}, '
'attempting to refresh tokens...\n'
'-- Headers: ${request.headers}',
);

final refreshedTokens = await _refreshTokens();
if (refreshedTokens == null) {
await _authCubitHandle.logOut();
return null;
}

final updatedRequest = applyHeader(
request,
'Authorization',
'Bearer ${refreshedTokens.jwt}',
);

return updatedRequest.copyWith(
headers: {...updatedRequest.headers, _retryHeader: 'true'},
);
}

Future<AuthTokens?> _refreshTokens() async {
if (_refreshCompleter != null) return _refreshCompleter!.future;

final completer = Completer<AuthTokens?>();
_refreshCompleter = completer;

try {
final tokensEither = await _authTokenRepository.getTokens().run();
final existingTokens = tokensEither.match(
(_) => null,
(maybeTokens) => maybeTokens.match(
() => null,
(tokens) => tokens,
),
);

if (existingTokens == null) {
log('Token refresh aborted: no tokens found in storage.');
completer.complete(null);
return completer.future;
}

final refreshResponse = await _tokenRefreshApi.accountAuthPost(
body: TokenLoginRequest(token: existingTokens.refreshToken),
);
final responseBody = refreshResponse.body;

if (!refreshResponse.isSuccessful || responseBody == null) {
log(
'Token refresh failed: server responded with '
'${refreshResponse.statusCode}.',
);
completer.complete(null);
return completer.future;
}

final newTokens = AuthTokens(
jwt: responseBody.jwt,
refreshToken: responseBody.refreshToken,
);

final savedTokens = await _authTokenRepository
.saveTokens(newTokens)
.match((_) => null, (tokens) => tokens)
.run();

if (savedTokens != null) {
log('Token refresh succeeded.');
} else {
log('Token refresh succeeded but saving new tokens failed.');
}
completer.complete(savedTokens);
return completer.future;
} on Exception catch (e) {
log('Token refresh failed with exception: $e.');
completer.complete(null);
return completer.future;
} finally {
_refreshCompleter = null;
}
}
}
35 changes: 35 additions & 0 deletions lib/login/bloc/auth_cubit_handle.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'dart:async';

import 'package:cafe_analog_app/http/http.dart';
import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart';
import 'package:chopper/chopper.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// Breaks the dependency cycle between the HTTP layer and the auth cubit.
///
/// [ChopperClient] (that contains [TokenRefreshAuthenticator]) is wired up in
/// a [RepositoryProvider] that runs before the [BlocProvider] for [AuthCubit].
/// This means the authenticator is constructed before a cubit instance exists,
/// so it cannot receive the cubit directly.
///
/// Instead, both are given this shared handle. Once the [BlocProvider] creates
/// [AuthCubit] it immediately calls [bind], after which any call to [logOut]
/// (e.g. when token refresh fails and the user must be signed out) is
/// forwarded to the live cubit. The internal [Completer] guarantees that
/// calls arriving before [bind] has run are safely queued rather than dropped.
class AuthCubitHandle {
final _completer = Completer<AuthCubit>();
AuthCubit? _cubit;

void bind(AuthCubit cubit) {
_cubit = cubit;
if (!_completer.isCompleted) {
_completer.complete(cubit);
}
}

Future<void> logOut() async {
final cubit = _cubit ?? await _completer.future;
await cubit.logOut();
}
}
6 changes: 0 additions & 6 deletions lib/login/bloc/authentication_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,4 @@ class AuthCubit extends Cubit<AuthState> {

emit(newState);
}

/// Refresh the JWT token.
Future<void> refreshToken({required AuthTokens tokens}) async {
// FIXME(marfavi): implement token refresh logic
throw UnimplementedError();
}
}
Loading