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: