diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b6a1ee2..713320b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -34,6 +34,9 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+ // OIDC redirect scheme for flutter_appauth's RedirectUriReceiverActivity.
+ // Must match the backend's redirect URI scheme (schulytest://callback).
+ manifestPlaceholders["appAuthRedirectScheme"] = "schulytest"
}
flavorDimensions += "environment"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 79f249d..8b1b917 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,7 +12,6 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
- android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -29,15 +28,10 @@
-
-
-
-
-
-
-
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 999e105..bfc370c 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -48,10 +48,10 @@
UIApplicationSupportsIndirectInputEvents
+ redirect scheme (schulytest://callback), driven by flutter_appauth over
+ ASWebAuthenticationSession. Registering it here lets iOS route the login
+ callback back into the app. The scheme matches the backend's redirectUri
+ / OidcConfig fallback. -->
CFBundleURLTypes
diff --git a/lib/config/oidc_config.dart b/lib/config/oidc_config.dart
index d28c735..8a8e531 100644
--- a/lib/config/oidc_config.dart
+++ b/lib/config/oidc_config.dart
@@ -16,6 +16,7 @@ class OidcSettings {
final String redirectUri;
final String authorizationEndpoint;
final String tokenEndpoint;
+ final String? endSessionEndpoint;
const OidcSettings({
required this.authority,
@@ -24,8 +25,12 @@ class OidcSettings {
required this.redirectUri,
required this.authorizationEndpoint,
required this.tokenEndpoint,
+ this.endSessionEndpoint,
});
+ /// The OIDC scopes as a list, split from the space-delimited [scope] string.
+ List get scopes => scope.split(' ').where((s) => s.isNotEmpty).toList();
+
/// Deep-link scheme the provider redirects back to (e.g. `schulytest`),
/// derived from [redirectUri] so the app never hardcodes it.
String get callbackScheme => Uri.parse(redirectUri).scheme;
@@ -76,6 +81,7 @@ class OidcConfig {
redirectUri: (app['redirectUri'] as String?) ?? 'schulytest://callback',
authorizationEndpoint: disco['authorization_endpoint'] as String,
tokenEndpoint: disco['token_endpoint'] as String,
+ endSessionEndpoint: disco['end_session_endpoint'] as String?,
);
}
diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart
index 116ce48..f8c5058 100644
--- a/lib/services/auth_service.dart
+++ b/lib/services/auth_service.dart
@@ -1,181 +1,122 @@
import 'dart:async';
import 'dart:convert';
-import 'dart:math';
import 'package:flutter/foundation.dart';
-import 'package:app_links/app_links.dart';
-import 'package:crypto/crypto.dart';
+import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
-import 'package:url_launcher/url_launcher.dart';
import '../config/oidc_config.dart';
class AuthTokens {
final String accessToken;
final String? idToken;
final String? refreshToken;
- final int? expiresIn;
-
- AuthTokens({
- required this.accessToken,
- this.idToken,
- this.refreshToken,
- this.expiresIn,
- });
+ final DateTime? accessTokenExpiry;
+
+ AuthTokens({required this.accessToken, this.idToken, this.refreshToken, this.accessTokenExpiry});
}
+/// OIDC Authorization Code + PKCE login backed by [FlutterAppAuth], which drives
+/// the flow through ASWebAuthenticationSession (iOS) / Chrome Custom Tabs
+/// (Android) - the system browser, never a WebView, and never an in-app
+/// credential form. The client is public (no secret); PKCE (S256) replaces it.
class AuthService {
- static const _kAccessTokenKey = 'auth.access_token';
- static const _kIdTokenKey = 'auth.id_token';
+ // Only the refresh token and id token are persisted. The access token is kept
+ // in memory (per OAuth mobile best practice) and re-minted from the refresh
+ // token on cold start.
static const _kRefreshTokenKey = 'auth.refresh_token';
+ static const _kIdTokenKey = 'auth.id_token';
+ // Legacy key from the old hand-rolled flow that persisted the access token.
+ static const _kLegacyAccessTokenKey = 'auth.access_token';
- /// OIDC tokens live in the platform keystore (Android EncryptedSharedPrefs /
- /// iOS Keychain), not plaintext SharedPreferences.
+ static const _appAuth = FlutterAppAuth();
+
+ /// Refresh/id tokens live in the platform keystore (Android
+ /// EncryptedSharedPrefs / iOS Keychain), not plaintext SharedPreferences.
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
+ /// The current access token, held in memory only, with its expiry.
+ static String? _accessToken;
+ static DateTime? _accessTokenExpiry;
+
/// One-shot migration of tokens written by older builds into SharedPreferences.
/// Memoised so it runs at most once per process.
static Future? _migration;
static Future _ensureMigrated() => _migration ??= _migrate();
static Future _migrate() async {
+ // Drop any access token an old build persisted - it's memory-only now.
+ await _storage.delete(key: _kLegacyAccessTokenKey);
+
// Already in secure storage → nothing to carry over.
- if (await _storage.containsKey(key: _kAccessTokenKey)) return;
+ if (await _storage.containsKey(key: _kRefreshTokenKey)) return;
final prefs = await SharedPreferences.getInstance();
- final access = prefs.getString(_kAccessTokenKey);
- if (access == null) return; // fresh install or already migrated + signed out
+ final refresh = prefs.getString(_kRefreshTokenKey);
+ if (refresh == null) return; // fresh install or already migrated + signed out
final idToken = prefs.getString(_kIdTokenKey);
- final refresh = prefs.getString(_kRefreshTokenKey);
- await _storage.write(key: _kAccessTokenKey, value: access);
+ await _storage.write(key: _kRefreshTokenKey, value: refresh);
if (idToken != null) await _storage.write(key: _kIdTokenKey, value: idToken);
- if (refresh != null) await _storage.write(key: _kRefreshTokenKey, value: refresh);
- await prefs.remove(_kAccessTokenKey);
+ await prefs.remove(_kLegacyAccessTokenKey);
await prefs.remove(_kIdTokenKey);
await prefs.remove(_kRefreshTokenKey);
}
- static final AppLinks _appLinks = AppLinks();
-
/// Bumped whenever the session changes (sign-out / expiry). The auth gate
/// listens to re-evaluate whether to show the sign-in screen.
static final ValueNotifier sessionEpoch = ValueNotifier(0);
- /// Full OIDC PKCE flow via the external browser:
- /// 1. Build authorize URL + PKCE pair.
- /// 2. Subscribe to deep links *before* launching the browser so we cannot
- /// miss the callback if the user returns instantly.
- /// 3. Open the URL in Chrome (external, supports passkeys).
- /// 4. Await the first incoming [OidcConfig.redirectUri] deep link.
- /// 5. Exchange the auth code for tokens.
- /// Runs the OIDC PKCE flow. When [register] is true the authorize request
- /// carries `prompt=create` (the OIDC registration hint), so providers that
- /// support it - e.g. Keycloak - open the registration screen first; others
- /// ignore it and fall back to login.
- static Future signIn({bool register = false}) async {
- final cfg = await OidcConfig.settings();
- final (verifier, challenge) = _generatePkce();
- final state = DateTime.now().microsecondsSinceEpoch.toString();
- final authorizeUrl = Uri.parse(cfg.authorizationEndpoint).replace(
- queryParameters: {
- 'response_type': 'code',
- 'client_id': cfg.clientId,
- 'redirect_uri': cfg.redirectUri,
- 'scope': cfg.scope,
- 'state': state,
- 'code_challenge': challenge,
- 'code_challenge_method': 'S256',
- if (register) 'prompt': 'create',
- },
- );
-
- final completer = Completer();
- late final StreamSubscription sub;
- sub = _appLinks.uriLinkStream.listen((uri) {
- if (uri.scheme == cfg.callbackScheme && !completer.isCompleted) {
- completer.complete(uri);
- }
- });
-
- try {
- final launched = await launchUrl(authorizeUrl, mode: LaunchMode.externalApplication);
- if (!launched) throw Exception('Could not launch browser');
-
- final callback = await completer.future.timeout(
- const Duration(minutes: 5),
- onTimeout: () => throw Exception('Login timed out'),
- );
-
- final error = callback.queryParameters['error'];
- if (error != null) throw Exception('OIDC error: $error');
- final code = callback.queryParameters['code'];
- final returnedState = callback.queryParameters['state'];
- if (code == null) throw Exception('Callback missing code');
- if (returnedState != state) throw Exception('State mismatch');
-
- return await _exchangeCode(code: code, codeVerifier: verifier);
- } finally {
- await sub.cancel();
- }
- }
-
- static (String, String) _generatePkce() {
- final rand = Random.secure();
- final bytes = List.generate(32, (_) => rand.nextInt(256));
- final verifier = base64UrlEncode(bytes).replaceAll('=', '');
- final challenge = base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes)
- .replaceAll('=', '');
- return (verifier, challenge);
- }
+ static AuthorizationServiceConfiguration _serviceConfig(OidcSettings cfg) => AuthorizationServiceConfiguration(authorizationEndpoint: cfg.authorizationEndpoint, tokenEndpoint: cfg.tokenEndpoint, endSessionEndpoint: cfg.endSessionEndpoint);
- static Future _exchangeCode({
- required String code,
- required String codeVerifier,
- }) async {
+ /// Runs the OIDC Authorization Code + PKCE flow in the system browser and
+ /// exchanges the code for tokens. flutter_appauth applies PKCE (S256)
+ /// automatically. When [register] is true the authorize request carries
+ /// `prompt=create` (the OIDC registration hint) so Keycloak opens the
+ /// registration screen first.
+ static Future signIn({bool register = false}) async {
final cfg = await OidcConfig.settings();
- final response = await http.post(
- Uri.parse(cfg.tokenEndpoint),
- headers: {'Content-Type': 'application/x-www-form-urlencoded'},
- body: {
- 'grant_type': 'authorization_code',
- 'code': code,
- 'client_id': cfg.clientId,
- 'redirect_uri': cfg.redirectUri,
- 'code_verifier': codeVerifier,
- },
- );
- if (response.statusCode != 200) {
- throw Exception('Token exchange failed (${response.statusCode}): ${response.body}');
- }
- final data = jsonDecode(response.body) as Map;
- final tokens = AuthTokens(
- accessToken: data['access_token'] as String,
- idToken: data['id_token'] as String?,
- refreshToken: data['refresh_token'] as String?,
- expiresIn: data['expires_in'] as int?,
+ final result = await _appAuth.authorizeAndExchangeCode(
+ AuthorizationTokenRequest(
+ cfg.clientId,
+ cfg.redirectUri,
+ serviceConfiguration: _serviceConfig(cfg),
+ scopes: cfg.scopes,
+ promptValues: register ? const ['create'] : null,
+ ),
);
- await _persist(tokens);
- return tokens;
+ return _persist(result);
}
- static Future _persist(AuthTokens tokens) async {
+ static Future _persist(TokenResponse r) async {
await _ensureMigrated();
- await _storage.write(key: _kAccessTokenKey, value: tokens.accessToken);
- if (tokens.idToken != null) {
- await _storage.write(key: _kIdTokenKey, value: tokens.idToken!);
- }
+ final tokens = AuthTokens(accessToken: r.accessToken!, idToken: r.idToken, refreshToken: r.refreshToken, accessTokenExpiry: r.accessTokenExpirationDateTime);
+ _accessToken = tokens.accessToken;
+ _accessTokenExpiry = tokens.accessTokenExpiry;
+ // Keycloak rotates refresh tokens by default; persist the new one every time
+ // or the next refresh fails. Fall back to the existing one if omitted.
if (tokens.refreshToken != null) {
await _storage.write(key: _kRefreshTokenKey, value: tokens.refreshToken!);
}
+ if (tokens.idToken != null) {
+ await _storage.write(key: _kIdTokenKey, value: tokens.idToken!);
+ }
+ return tokens;
}
+ /// Returns a usable access token: the in-memory one if still valid, otherwise
+ /// a freshly refreshed one. Null when there's no session (no refresh token or
+ /// the refresh failed) - the caller should treat that as signed-out.
static Future getAccessToken() async {
- await _ensureMigrated();
- return _storage.read(key: _kAccessTokenKey);
+ final token = _accessToken;
+ final expiry = _accessTokenExpiry;
+ // Treat tokens within 30s of expiry as stale to avoid using one mid-flight.
+ if (token != null && expiry != null && expiry.isAfter(DateTime.now().add(const Duration(seconds: 30)))) {
+ return token;
+ }
+ return refreshAccessToken();
}
static Future getRefreshToken() async {
@@ -183,35 +124,31 @@ class AuthService {
return _storage.read(key: _kRefreshTokenKey);
}
+ /// In-flight refresh, shared so concurrent callers trigger a single token
+ /// exchange instead of a stampede.
+ static Future? _refreshing;
+
/// Exchanges the stored refresh token for a fresh access token and persists
- /// the result. Returns the new access token, or null if there's no refresh
- /// token or the exchange failed - in which case the caller should treat the
- /// session as expired.
- static Future refreshAccessToken() async {
+ /// the rotated result. Returns the new access token, or null if there's no
+ /// refresh token or the exchange failed - in which case the caller should
+ /// treat the session as expired.
+ static Future refreshAccessToken() => _refreshing ??= _refresh().whenComplete(() => _refreshing = null);
+
+ static Future _refresh() async {
final refreshToken = await getRefreshToken();
if (refreshToken == null) return null;
try {
final cfg = await OidcConfig.settings();
- final response = await http.post(
- Uri.parse(cfg.tokenEndpoint),
- headers: {'Content-Type': 'application/x-www-form-urlencoded'},
- body: {
- 'grant_type': 'refresh_token',
- 'refresh_token': refreshToken,
- 'client_id': cfg.clientId,
- },
+ final result = await _appAuth.token(
+ TokenRequest(
+ cfg.clientId,
+ cfg.redirectUri,
+ serviceConfiguration: _serviceConfig(cfg),
+ refreshToken: refreshToken,
+ scopes: cfg.scopes,
+ ),
);
- if (response.statusCode != 200) return null;
- final data = jsonDecode(response.body) as Map;
- final tokens = AuthTokens(
- accessToken: data['access_token'] as String,
- idToken: data['id_token'] as String?,
- // Pocket ID rotates refresh tokens; fall back to the old one if the
- // response omits a new one.
- refreshToken: (data['refresh_token'] as String?) ?? refreshToken,
- expiresIn: data['expires_in'] as int?,
- );
- await _persist(tokens);
+ final tokens = await _persist(result);
return tokens.accessToken;
} catch (_) {
return null;
@@ -236,13 +173,34 @@ class AuthService {
}
}
+ /// Full logout: end the Keycloak SSO session at the `end_session_endpoint`
+ /// (deleting local tokens alone leaves the browser session alive, so the next
+ /// login would silently succeed), then wipe local state. The end-session call
+ /// is best-effort - local state is cleared regardless.
static Future signOut() async {
- await _storage.delete(key: _kAccessTokenKey);
- await _storage.delete(key: _kIdTokenKey);
+ final idToken = await _storage.read(key: _kIdTokenKey);
+ try {
+ final cfg = await OidcConfig.settings();
+ if (cfg.endSessionEndpoint != null) {
+ await _appAuth.endSession(
+ EndSessionRequest(
+ idTokenHint: idToken,
+ postLogoutRedirectUrl: cfg.redirectUri,
+ serviceConfiguration: _serviceConfig(cfg),
+ ),
+ );
+ }
+ } catch (_) {
+ // Ignore - the user still gets signed out locally below.
+ }
+ _accessToken = null;
+ _accessTokenExpiry = null;
await _storage.delete(key: _kRefreshTokenKey);
+ await _storage.delete(key: _kIdTokenKey);
+ await _storage.delete(key: _kLegacyAccessTokenKey);
// Clear any tokens an older build may have left in SharedPreferences too.
final prefs = await SharedPreferences.getInstance();
- await prefs.remove(_kAccessTokenKey);
+ await prefs.remove(_kLegacyAccessTokenKey);
await prefs.remove(_kIdTokenKey);
await prefs.remove(_kRefreshTokenKey);
sessionEpoch.value++;
diff --git a/pubspec.lock b/pubspec.lock
index 41d0a40..d6eaca0 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -17,38 +17,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "10.0.1"
- app_links:
- dependency: "direct main"
- description:
- name: app_links
- sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
- url: "https://pub.dev"
- source: hosted
- version: "6.4.1"
- app_links_linux:
- dependency: transitive
- description:
- name: app_links_linux
- sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
- url: "https://pub.dev"
- source: hosted
- version: "1.0.3"
- app_links_platform_interface:
- dependency: transitive
- description:
- name: app_links_platform_interface
- sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
- url: "https://pub.dev"
- source: hosted
- version: "2.0.2"
- app_links_web:
- dependency: transitive
- description:
- name: app_links_web
- sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
- url: "https://pub.dev"
- source: hosted
- version: "1.0.4"
archive:
dependency: transitive
description:
@@ -73,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
+ base32:
+ dependency: transitive
+ description:
+ name: base32
+ sha256: "37548444aaee8bd5e91db442ce69ee3a79d3652ed47c1fa7568aa3bb9af0aea5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
boolean_selector:
dependency: transitive
description:
@@ -101,10 +77,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
- version: "1.4.1"
+ version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -222,6 +198,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_appauth:
+ dependency: "direct main"
+ description:
+ name: flutter_appauth
+ sha256: aafcafdc7de1c412f361798c993de5737a3eddc6bf26fe38a71ad580b37b4ec0
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.2"
+ flutter_appauth_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_appauth_platform_interface
+ sha256: b7c7d4f288af7b3119a9db0ea00cf5e93135d0e83c3687172848bc5c4fdec992
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
@@ -333,14 +325,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.3"
- gtk:
- dependency: transitive
- description:
- name: gtk
- sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
http:
dependency: "direct main"
description:
@@ -437,30 +421,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
- sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
- version: "1.18.0"
+ version: "1.17.0"
mime:
dependency: transitive
description:
@@ -469,6 +461,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
+ mobile_scanner:
+ dependency: "direct main"
+ description:
+ name: mobile_scanner
+ sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.3"
nitrogen_types:
dependency: transitive
description:
@@ -501,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.7.0"
+ otp:
+ dependency: "direct main"
+ description:
+ name: otp
+ sha256: "998e73b5cd831a13fa654cf2cbb169d027f74b4928853ff26aa9311ead66ed7c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.0"
package_config:
dependency: transitive
description:
@@ -733,10 +741,18 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
+ sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.7"
+ timezone:
+ dependency: transitive
+ description:
+ name: timezone
+ sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
- version: "0.7.11"
+ version: "0.10.1"
typed_data:
dependency: transitive
description:
@@ -875,4 +891,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
- flutter: ">=3.38.0"
+ flutter: ">=3.38.1"
diff --git a/pubspec.yaml b/pubspec.yaml
index d8a2c4f..6cd4835 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -46,7 +46,6 @@ dependencies:
crypto: ^3.0.5
shared_preferences: ^2.3.2
flutter_secure_storage: ^9.2.2
- app_links: ^6.3.2
forui: ^0.17.0
forui_assets: ^0.17.1
path_provider: ^2.1.2
@@ -55,6 +54,7 @@ dependencies:
otp: ^3.1.4
# QR scanning to capture the TOTP seed.
mobile_scanner: ^5.2.3
+ flutter_appauth: ^12.0.2
dev_dependencies:
flutter_test: