Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a6e9de4a8 | ||
|
|
4c377b19c4 | ||
|
|
8494b4ad7f | ||
|
|
e596b99e39 |
+5
-4
@@ -13,11 +13,12 @@ pubspec.lock
|
|||||||
*.gr.dart
|
*.gr.dart
|
||||||
*.mocks.dart
|
*.mocks.dart
|
||||||
|
|
||||||
# Keep version.g.dart - it's generated but should be committed
|
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
|
||||||
# so CI/CD builds have version info without running the generator
|
# These are regenerated by build_runner during CI/CD builds
|
||||||
# Other *.g.dart files (from json_serializable, etc.) are ignored
|
|
||||||
lib/**/*.g.dart
|
lib/**/*.g.dart
|
||||||
!lib/version.g.dart
|
|
||||||
|
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
|
||||||
|
web/health.json
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed version generation in CI/CD builds
|
||||||
|
- Removed generated files (version.g.dart, health.json) from git tracking
|
||||||
|
- These files are now regenerated from pubspec.yaml during Docker build
|
||||||
|
|
||||||
## [1.0.5] - 2026-01-04
|
## [1.0.5] - 2026-01-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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
-5
@@ -14,9 +14,5 @@ void main() {
|
|||||||
name: 'tatlock_ui',
|
name: 'tatlock_ui',
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(
|
runApp(const ProviderScope(child: TatlockApp()));
|
||||||
const ProviderScope(
|
|
||||||
child: TatlockApp(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
// GENERATED FILE - DO NOT EDIT
|
|
||||||
// Generated by build_runner from pubspec.yaml
|
|
||||||
|
|
||||||
/// Application version information from pubspec.yaml
|
|
||||||
class AppVersion {
|
|
||||||
AppVersion._();
|
|
||||||
|
|
||||||
static const String name = 'tatlock_ui';
|
|
||||||
static const String description = 'Tatlock - a Home Lab AI';
|
|
||||||
static const String version = '1.0.4';
|
|
||||||
static const int buildNumber = 1;
|
|
||||||
static const String fullVersion = '1.0.4+1';
|
|
||||||
}
|
|
||||||
+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.5+1
|
version: 1.0.7+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"name": "tatlock_ui",
|
|
||||||
"title": "Tatlock - a Home Lab AI",
|
|
||||||
"version": "1.0.4",
|
|
||||||
"buildNumber": 1,
|
|
||||||
"fullVersion": "1.0.4+1"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user