Compare commits

...
4 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 0a6e9de4a8 fix(auth): persist PKCE state in sessionStorage
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Store OIDC code_verifier and state in sessionStorage instead of
static memory variables. This fixes the "No code verifier" error
that occurred after Authentik redirect because the Flutter app
restarts and loses in-memory state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:40:38 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4c377b19c4 chore: release v1.0.6
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Fix version generation in CI/CD builds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:15:14 +01:00
Jeroen Schweitzer 8494b4ad7f add version debug print to console on start 2026-01-04 12:15:05 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e596b99e39 chore: stop tracking generated files
- Remove version.g.dart and health.json from git
- These are now regenerated during CI/CD build from pubspec.yaml
- Fixes version mismatch issue in deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:12:10 +01:00
9 changed files with 83 additions and 47 deletions
+5 -4
View File
@@ -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/
+14
View File
@@ -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
+32 -16
View File
@@ -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
View File
@@ -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');
}
+15
View File
@@ -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
View File
@@ -14,9 +14,5 @@ void main() {
name: 'tatlock_ui', name: 'tatlock_ui',
); );
runApp( runApp(const ProviderScope(child: TatlockApp()));
const ProviderScope(
child: TatlockApp(),
),
);
} }
-13
View File
@@ -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
View File
@@ -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
-8
View File
@@ -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"
}