Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a6e9de4a8 |
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.6] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import 'oidc_service.dart';
|
import 'oidc_service.dart';
|
||||||
|
import 'web_utils.dart' as web_utils;
|
||||||
|
|
||||||
/// Web implementation of OIDC service using browser redirect flow.
|
/// Web implementation of OIDC service using browser redirect flow.
|
||||||
///
|
///
|
||||||
@@ -25,9 +26,9 @@ class OidcServiceWeb implements OidcService {
|
|||||||
/// Redirect URI for web.
|
/// Redirect URI for web.
|
||||||
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
|
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
|
||||||
|
|
||||||
// PKCE state stored during auth flow (in-memory for single-page app)
|
// SessionStorage keys for PKCE state (persists across redirect)
|
||||||
static String? _codeVerifier;
|
static const _codeVerifierKey = 'oidc_code_verifier';
|
||||||
static String? _state;
|
static const _stateKey = 'oidc_state';
|
||||||
|
|
||||||
/// Get the authorization URL to redirect the browser to.
|
/// Get the authorization URL to redirect the browser to.
|
||||||
///
|
///
|
||||||
@@ -39,11 +40,15 @@ class OidcServiceWeb implements OidcService {
|
|||||||
final authEndpoint = discovery['authorization_endpoint'] as String;
|
final authEndpoint = discovery['authorization_endpoint'] as String;
|
||||||
|
|
||||||
// Generate PKCE code verifier and challenge
|
// Generate PKCE code verifier and challenge
|
||||||
_codeVerifier = _generateCodeVerifier();
|
final codeVerifier = _generateCodeVerifier();
|
||||||
final codeChallenge = _generateCodeChallenge(_codeVerifier!);
|
final codeChallenge = _generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
// Generate state for CSRF protection
|
// 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
|
// Build authorization URL
|
||||||
final params = {
|
final params = {
|
||||||
@@ -53,7 +58,7 @@ class OidcServiceWeb implements OidcService {
|
|||||||
'scope': _scopes.join(' '),
|
'scope': _scopes.join(' '),
|
||||||
'code_challenge': codeChallenge,
|
'code_challenge': codeChallenge,
|
||||||
'code_challenge_method': 'S256',
|
'code_challenge_method': 'S256',
|
||||||
'state': _state,
|
'state': state,
|
||||||
};
|
};
|
||||||
|
|
||||||
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
|
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.
|
/// [code] is the authorization code from the callback URL.
|
||||||
/// [state] is the state parameter from the callback URL (verified for CSRF).
|
/// [state] is the state parameter from the callback URL (verified for CSRF).
|
||||||
Future<OidcTokens> exchangeCode(String code, String state) async {
|
Future<OidcTokens> 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
|
// Verify state matches
|
||||||
if (_state == null || state != _state) {
|
if (storedState == null || state != storedState) {
|
||||||
|
_clearPkceState();
|
||||||
throw OidcException('State mismatch - possible CSRF attack');
|
throw OidcException('State mismatch - possible CSRF attack');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_codeVerifier == null) {
|
if (codeVerifier == null) {
|
||||||
|
_clearPkceState();
|
||||||
throw OidcException('No code verifier - flow not started properly');
|
throw OidcException('No code verifier - flow not started properly');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +105,7 @@ class OidcServiceWeb implements OidcService {
|
|||||||
'client_id': AppConfig.authClientId,
|
'client_id': AppConfig.authClientId,
|
||||||
'redirect_uri': _redirectUri,
|
'redirect_uri': _redirectUri,
|
||||||
'code': code,
|
'code': code,
|
||||||
'code_verifier': _codeVerifier,
|
'code_verifier': codeVerifier,
|
||||||
},
|
},
|
||||||
options: Options(
|
options: Options(
|
||||||
contentType: Headers.formUrlEncodedContentType,
|
contentType: Headers.formUrlEncodedContentType,
|
||||||
@@ -102,8 +116,7 @@ class OidcServiceWeb implements OidcService {
|
|||||||
developer.log('Token exchange successful', name: 'oidc_web');
|
developer.log('Token exchange successful', name: 'oidc_web');
|
||||||
|
|
||||||
// Clear stored PKCE state
|
// Clear stored PKCE state
|
||||||
_codeVerifier = null;
|
_clearPkceState();
|
||||||
_state = null;
|
|
||||||
|
|
||||||
return OidcTokens(
|
return OidcTokens(
|
||||||
accessToken: data['access_token'] as String,
|
accessToken: data['access_token'] as String,
|
||||||
@@ -115,14 +128,17 @@ class OidcServiceWeb implements OidcService {
|
|||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
developer.log('Token exchange failed: $e', name: 'oidc_web');
|
developer.log('Token exchange failed: $e', name: 'oidc_web');
|
||||||
|
_clearPkceState();
|
||||||
throw OidcException('Token exchange failed: ${e.message}');
|
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.
|
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
|
||||||
@override
|
@override
|
||||||
Future<OidcTokens> signIn() async {
|
Future<OidcTokens> signIn() async {
|
||||||
|
|||||||
@@ -15,3 +15,18 @@ String getCurrentUrl() {
|
|||||||
void replaceUrl(String url) {
|
void replaceUrl(String url) {
|
||||||
throw UnsupportedError('replaceUrl is only supported on web');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,18 @@ String getCurrentUrl() {
|
|||||||
void replaceUrl(String url) {
|
void replaceUrl(String url) {
|
||||||
web.window.history.replaceState(null, '', 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);
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user