Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="vikunja" android:host="callback" />
</intent-filter>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
Expand Down
11 changes: 11 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,16 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vikunja</string>
</array>
</dict>
</array>
</dict>
</plist>
6 changes: 6 additions & 0 deletions lib/core/di/data_source_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vikunja_app/core/di/network_provider.dart';
import 'package:vikunja_app/data/data_sources/bucket_data_source.dart';
import 'package:vikunja_app/data/data_sources/label_data_source.dart';
import 'package:vikunja_app/data/data_sources/oauth_data_source.dart';
import 'package:vikunja_app/data/data_sources/project_data_source.dart';
import 'package:vikunja_app/data/data_sources/project_view_data_source.dart';
import 'package:vikunja_app/data/data_sources/server_data_source.dart';
Expand Down Expand Up @@ -86,3 +87,8 @@ TaskCommentDataSource taskCommentDataSource(Ref ref) {
final client = ref.watch(clientProviderProvider);
return TaskCommentDataSource(client);
}

@riverpod
OAuthDataSource oAuthDataSource(Ref ref) {
return OAuthDataSource();
}
17 changes: 17 additions & 0 deletions lib/core/di/data_source_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 116 additions & 1 deletion lib/core/di/network_provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import 'dart:async';
import 'dart:developer' as developer;

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vikunja_app/core/di/data_source_provider.dart';
import 'package:vikunja_app/core/di/repository_provider.dart';
import 'package:vikunja_app/core/network/client.dart';
import 'package:vikunja_app/data/data_sources/oauth_data_source.dart';
import 'package:vikunja_app/domain/entities/auth_model.dart';
import 'package:vikunja_app/domain/entities/user.dart';

Expand All @@ -14,6 +20,14 @@ class AuthData extends _$AuthData {
state = token;
ref.invalidate(clientProviderProvider);
}

/// Updates only the token without invalidating ClientProvider.
/// Used by the OAuth refresh flow to update authData while keeping
/// the current Client instance alive (in-place token update).
void updateToken(String token) {
if (state == null) return;
state = AuthModel(state!.address, token);
}
}

@Riverpod(keepAlive: true)
Expand All @@ -24,12 +38,113 @@ class CurrentUser extends _$CurrentUser {
void set(User user) => state = user;
}

/// Holds OAuth-specific state: refresh token and access token expiry.
/// Null when the session is a password-based login.
class OAuthTokenState {
final String refreshToken;
final DateTime expiresAt;
OAuthTokenState({required this.refreshToken, required this.expiresAt});
}

@Riverpod(keepAlive: true)
class OAuthTokenManager extends _$OAuthTokenManager {
Completer<void>? _refreshLock;

@override
OAuthTokenState? build() => null;

void setTokens(OAuthTokenState tokens) => state = tokens;

void clear() => state = null;

bool get isOAuth => state != null;

bool get needsRefresh =>
state != null &&
state!.expiresAt.isBefore(
DateTime.now().add(const Duration(seconds: 30)),
);

/// Ensures the access token is valid, refreshing if needed.
/// Serializes concurrent calls so only one refresh request fires.
Future<void> ensureValidToken(Client client) async {
if (!needsRefresh) return;

// If a refresh is already in progress, wait for it
if (_refreshLock != null) {
await _refreshLock!.future;
return;
}

_refreshLock = Completer<void>();
try {
await _doRefresh(client);
_refreshLock!.complete();
} catch (e) {
_refreshLock!.completeError(e);
rethrow;
} finally {
_refreshLock = null;
}
}

Future<void> _doRefresh(Client client) async {
final oauthDataSource = ref.read(oAuthDataSourceProvider);
final settingsRepo = ref.read(settingsRepositoryProvider);
final baseUrl = ref.read(authDataProvider)?.address;

if (baseUrl == null || state == null) return;

try {
final tokens = await oauthDataSource.refreshToken(
baseUrl: baseUrl,
refreshToken: state!.refreshToken,
);

// Update in-memory OAuth state
state = OAuthTokenState(
refreshToken: tokens.refreshToken,
expiresAt: DateTime.now().add(Duration(seconds: tokens.expiresIn)),
);

// Update the Client's token in-place (current request uses new token)
client.token = tokens.accessToken;

// Update authData without invalidating ClientProvider
ref.read(authDataProvider.notifier).updateToken(tokens.accessToken);

// Persist to storage
await settingsRepo.saveUserToken(tokens.accessToken);
await settingsRepo.saveRefreshToken(tokens.refreshToken);
await settingsRepo.saveTokenExpiry(state!.expiresAt);
} on OAuthException catch (e) {
developer.log('OAuth refresh failed: $e');
// Clear OAuth state — session is gone
state = null;
await settingsRepo.saveRefreshToken(null);
await settingsRepo.saveTokenExpiry(null);
await settingsRepo.saveAuthType(null);
rethrow;
}
}
}

@Riverpod(keepAlive: true)
class ClientProvider extends _$ClientProvider {
@override
Client build() {
final authData = ref.read(authDataProvider);
final client = Client(
base: authData?.address ?? '',
token: authData?.token,
);

// If this is an OAuth session, wire up proactive token refresh
final oauthManager = ref.read(oAuthTokenManagerProvider.notifier);
if (oauthManager.isOAuth) {
client.onBeforeRequest = (c) => oauthManager.ensureValidToken(c);
}

return Client(base: authData?.address ?? '', token: authData?.token);
return client;
}
}
20 changes: 18 additions & 2 deletions lib/core/di/network_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions lib/core/network/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ class Client {

String get token => _token;

set token(String value) => _token = value;

/// Called before each HTTP request. Used by OAuth to refresh tokens proactively.
Future<void> Function(Client client)? onBeforeRequest;

Client({String? token, required String base}) {
if (token != null) _token = token;
base = base.replaceAll(" ", "");
Expand Down Expand Up @@ -91,6 +96,7 @@ class Client {
Map<String, List<String>>? queryParameters,
}) async {
try {
if (onBeforeRequest != null) await onBeforeRequest!(this);
Uri uri = Uri.tryParse('$base$url')!;

uri = Uri(
Expand All @@ -116,6 +122,7 @@ class Client {
T Function(dynamic body)? mapper,
}) async {
try {
if (onBeforeRequest != null) await onBeforeRequest!(this);
var response = await _httpClient.delete(
'$base$url'.toUri()!,
headers: _headers,
Expand All @@ -132,6 +139,7 @@ class Client {
dynamic body,
}) async {
try {
if (onBeforeRequest != null) await onBeforeRequest!(this);
var response = await _httpClient.post(
'$base$url'.toUri()!,
headers: _headers,
Expand All @@ -149,6 +157,7 @@ class Client {
dynamic body,
}) async {
try {
if (onBeforeRequest != null) await onBeforeRequest!(this);
var response = await _httpClient.put(
'$base$url'.toUri()!,
headers: _headers,
Expand Down
Loading
Loading