diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c7461..feb86ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.7] - 2026-01-04 + +### Fixed +- Fixed OIDC PKCE state loss across browser redirect + - Code verifier and state now persist in sessionStorage instead of memory + - Prevents "No code verifier" error after Authentik redirect + ## [1.0.6] - 2026-01-04 ### Fixed diff --git a/lib/core/auth/oidc_service_web.dart b/lib/core/auth/oidc_service_web.dart index 807257e..107f8ed 100644 --- a/lib/core/auth/oidc_service_web.dart +++ b/lib/core/auth/oidc_service_web.dart @@ -8,6 +8,7 @@ import 'package:dio/dio.dart'; import '../config/app_config.dart'; import 'oidc_service.dart'; +import 'web_utils.dart' as web_utils; /// Web implementation of OIDC service using browser redirect flow. /// @@ -25,9 +26,9 @@ class OidcServiceWeb implements OidcService { /// Redirect URI for web. static String get _redirectUri => '${AppConfig.webBaseUrl}/callback'; - // PKCE state stored during auth flow (in-memory for single-page app) - static String? _codeVerifier; - static String? _state; + // SessionStorage keys for PKCE state (persists across redirect) + static const _codeVerifierKey = 'oidc_code_verifier'; + static const _stateKey = 'oidc_state'; /// Get the authorization URL to redirect the browser to. /// @@ -39,11 +40,15 @@ class OidcServiceWeb implements OidcService { final authEndpoint = discovery['authorization_endpoint'] as String; // Generate PKCE code verifier and challenge - _codeVerifier = _generateCodeVerifier(); - final codeChallenge = _generateCodeChallenge(_codeVerifier!); + final codeVerifier = _generateCodeVerifier(); + final codeChallenge = _generateCodeChallenge(codeVerifier); // Generate state for CSRF protection - _state = _generateRandomString(32); + final state = _generateRandomString(32); + + // Store PKCE state in sessionStorage (persists across redirect) + web_utils.setSessionStorage(_codeVerifierKey, codeVerifier); + web_utils.setSessionStorage(_stateKey, state); // Build authorization URL final params = { @@ -53,7 +58,7 @@ class OidcServiceWeb implements OidcService { 'scope': _scopes.join(' '), 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', - 'state': _state, + 'state': state, }; final uri = Uri.parse(authEndpoint).replace(queryParameters: params); @@ -67,12 +72,21 @@ class OidcServiceWeb implements OidcService { /// [code] is the authorization code from the callback URL. /// [state] is the state parameter from the callback URL (verified for CSRF). Future exchangeCode(String code, String state) async { + // Retrieve PKCE state from sessionStorage + final storedState = web_utils.getSessionStorage(_stateKey); + final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey); + + developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web'); + developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web'); + // Verify state matches - if (_state == null || state != _state) { + if (storedState == null || state != storedState) { + _clearPkceState(); throw OidcException('State mismatch - possible CSRF attack'); } - if (_codeVerifier == null) { + if (codeVerifier == null) { + _clearPkceState(); throw OidcException('No code verifier - flow not started properly'); } @@ -91,7 +105,7 @@ class OidcServiceWeb implements OidcService { 'client_id': AppConfig.authClientId, 'redirect_uri': _redirectUri, 'code': code, - 'code_verifier': _codeVerifier, + 'code_verifier': codeVerifier, }, options: Options( contentType: Headers.formUrlEncodedContentType, @@ -102,8 +116,7 @@ class OidcServiceWeb implements OidcService { developer.log('Token exchange successful', name: 'oidc_web'); // Clear stored PKCE state - _codeVerifier = null; - _state = null; + _clearPkceState(); return OidcTokens( accessToken: data['access_token'] as String, @@ -115,14 +128,17 @@ class OidcServiceWeb implements OidcService { ); } on DioException catch (e) { developer.log('Token exchange failed: $e', name: 'oidc_web'); + _clearPkceState(); throw OidcException('Token exchange failed: ${e.message}'); - } finally { - // Clear PKCE state on error too - _codeVerifier = null; - _state = null; } } + /// Clear PKCE state from sessionStorage. + void _clearPkceState() { + web_utils.removeSessionStorage(_codeVerifierKey); + web_utils.removeSessionStorage(_stateKey); + } + /// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead. @override Future signIn() async { diff --git a/lib/core/auth/web_utils_stub.dart b/lib/core/auth/web_utils_stub.dart index 647d7ae..28a8b65 100644 --- a/lib/core/auth/web_utils_stub.dart +++ b/lib/core/auth/web_utils_stub.dart @@ -15,3 +15,18 @@ String getCurrentUrl() { void replaceUrl(String url) { throw UnsupportedError('replaceUrl is only supported on web'); } + +/// Store a value in sessionStorage (no-op on non-web). +void setSessionStorage(String key, String value) { + throw UnsupportedError('setSessionStorage is only supported on web'); +} + +/// Get a value from sessionStorage (no-op on non-web). +String? getSessionStorage(String key) { + throw UnsupportedError('getSessionStorage is only supported on web'); +} + +/// Remove a value from sessionStorage (no-op on non-web). +void removeSessionStorage(String key) { + throw UnsupportedError('removeSessionStorage is only supported on web'); +} diff --git a/lib/core/auth/web_utils_web.dart b/lib/core/auth/web_utils_web.dart index 5abbb5a..76b6b18 100644 --- a/lib/core/auth/web_utils_web.dart +++ b/lib/core/auth/web_utils_web.dart @@ -17,3 +17,18 @@ String getCurrentUrl() { void replaceUrl(String url) { web.window.history.replaceState(null, '', url); } + +/// Store a value in sessionStorage. +void setSessionStorage(String key, String value) { + web.window.sessionStorage.setItem(key, value); +} + +/// Get a value from sessionStorage. +String? getSessionStorage(String key) { + return web.window.sessionStorage.getItem(key); +} + +/// Remove a value from sessionStorage. +void removeSessionStorage(String key) { + web.window.sessionStorage.removeItem(key); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8d6d0d6..3ab47c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.6+1 +version: 1.0.7+1 environment: sdk: ^3.10.4