Compare commits

...
8 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
Jeroen SchweitzerandClaude Opus 4.5 9c41a8805d chore: release v1.0.5
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Web authentication now uses OIDC Authorization Code flow with PKCE
instead of NPM forward auth. Added callback route and web utilities.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:01:42 +01:00
Jeroen SchweitzerandClaude Opus 4.5 45de2591a7 feat(health): add health.json with version info
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Generate health.json from pubspec.yaml at build time
- health.html fetches and displays JSON with version, title, status
- Added tool/generate_health_json.dart script

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:51:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2fe6067085 fix(auth): correct endpoint path /auth/me → /auth/users/me
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:42:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ce2dfcd13c fix(build): add production API URLs to Docker build
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m57s
Dockerfile now passes --dart-define flags for CORE_API_URL and
TATLOCK_API_URL pointing to schweitz.net domains. This enables
requiresAuth=true, fixing auth being completely skipped in production.

Also added service port reference table to AGENTS.md.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:32 +01:00
17 changed files with 680 additions and 147 deletions
+5 -4
View File
@@ -13,11 +13,12 @@ pubspec.lock
*.gr.dart
*.mocks.dart
# Keep version.g.dart - it's generated but should be committed
# so CI/CD builds have version info without running the generator
# Other *.g.dart files (from json_serializable, etc.) are ignored
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
# These are regenerated by build_runner during CI/CD builds
lib/**/*.g.dart
!lib/version.g.dart
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
web/health.json
# IDE
.idea/
+12 -6
View File
@@ -22,14 +22,20 @@ This document contains instructions and documentation references for AI assistan
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
### 🐳 Deployment & Infrastructure
**⚠️ IMPORTANT: Service Port Reference**
| Service | LAN Port | External URL | Notes |
|---------|----------|--------------|-------|
| **Core API** | 8083 | `api.schweitz.net` | FastAPI backend for this UI |
| **Tatlock API** | 8000 | `tatlock.schweitz.net` | Legacy Python API (Ollama proxy) |
| **Tatlock UI** | 9999 | `home.schweitz.net` | This Flutter app |
* **Full stack documentation**: Available in the `portainer-core` repo
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Access: `curl http://192.168.86.149:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Contains: All service ports, URLs, Redis DB allocations, external domains
* **Tatlock deployment**:
* LAN: `http://192.168.86.149:8000`
* External: `tatlock.schweitz.net` (behind Authentik SSO)
* Redis DBs: 1 (memory), 6 (benchmarks)
* **Health check**: `curl http://192.168.86.149:8000/health`
* **Health checks**:
* Core API: `curl http://192.168.86.149:8083/health`
* Tatlock API: `curl http://192.168.86.149:8000/health`
### 🛡️ Git Discipline
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
+47
View File
@@ -7,6 +7,53 @@ 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
- 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
### Changed
- **Web authentication now uses OIDC** instead of NPM forward auth
- Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE
- Added `/callback` route to handle Authentik redirect after login
- Login page now shows "Sign in with Authentik" button for both web and mobile
- Tokens stored in SharedPreferences and synced with core-api via `/auth/sync`
- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms
- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access
### Fixed
- Removed cross-origin cookie dependency that caused authentication failures on web
## [1.0.4] - 2026-01-03
### Added
- `health.json` generated at build time with app version info
- `health.html` now displays version, title, and status from health.json
## [1.0.3] - 2026-01-03
### Fixed
- Fixed auth endpoint path: `/auth/me``/auth/users/me`
## [1.0.2] - 2026-01-03
### Fixed
- Production Docker build now uses correct API URLs
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
- This enables `requiresAuth=true` so authentication is actually triggered
- Updated AGENTS.md with clear service port reference table
## [1.0.1] - 2026-01-03
### Fixed
+8 -2
View File
@@ -15,8 +15,14 @@ COPY . .
# Generate code with build_runner
RUN dart run build_runner build --delete-conflicting-outputs
# Build for web release
RUN flutter build web --release
# Generate health.json with version info
RUN dart run tool/generate_health_json.dart
# Build for web release with production configuration
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
RUN flutter build web --release \
--dart-define=CORE_API_URL=https://api.schweitz.net \
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
# Stage 2: Serve with nginx
FROM nginx:alpine
+1 -1
View File
@@ -102,7 +102,7 @@ class AuthDatasource {
/// Returns user profile if authenticated via the proxy.
/// Throws 401 if not authenticated or accessing directly.
Future<AuthSyncResponse> getCurrentUser() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/me');
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
return AuthSyncResponse.fromJson(response.data!);
}
+89 -82
View File
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -10,19 +9,21 @@ import '../config/app_config.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
part 'auth_provider.g.dart';
/// Provides authentication state and operations.
///
/// Supports two authentication flows:
/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy)
/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth
/// Supports OIDC Authorization Code flow with PKCE on all platforms:
/// - **Web**: Browser redirect to Authentik, callback via /callback route
/// - **Mobile**: flutter_appauth with custom URL scheme
///
/// On web, the app calls GET /auth/me to check if user is authenticated
/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync.
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@riverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
@@ -39,71 +40,10 @@ class AuthNotifier extends _$AuthNotifier {
@override
Future<AuthState> build() async {
// On web with auth required, try to get user from NPM forward auth
if (kIsWeb && AppConfig.requiresAuth) {
final webAuth = await _tryWebAuth();
if (webAuth != null) {
return webAuth;
}
// If web auth failed, user needs to refresh to trigger NPM login
developer.log('Web auth not available - user may need to login via proxy', name: 'auth');
return const AuthState();
}
// On mobile or LAN, load from stored auth
// Load stored auth on all platforms
return _loadStoredAuth();
}
/// Try to authenticate via NPM forward auth (web only).
///
/// Returns AuthState if authenticated, null if not.
Future<AuthState?> _tryWebAuth() async {
try {
developer.log('Attempting web auth via /auth/me', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.getCurrentUser();
developer.log(
'Web auth successful: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Store auth data for offline/cached access
await _storeAuth(
accessToken: 'web-session', // Placeholder - web uses cookies
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
return AuthState(
isAuthenticated: true,
accessToken: 'web-session',
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
developer.log('Web auth: not authenticated via proxy', name: 'auth');
return null;
}
developer.log('Web auth error: $e', name: 'auth');
return null;
} catch (e) {
developer.log('Web auth unexpected error: $e', name: 'auth');
return null;
}
}
Future<AuthState> _loadStoredAuth() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -224,7 +164,7 @@ class AuthNotifier extends _$AuthNotifier {
/// Sign in with the appropriate method for the platform.
///
/// - **Web**: Triggers page reload to go through NPM forward auth
/// - **Web**: Redirects to Authentik for OIDC authentication
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
Future<void> signIn() async {
if (!AppConfig.requiresAuth) {
@@ -234,25 +174,25 @@ class AuthNotifier extends _$AuthNotifier {
return;
}
// On web, auth is handled by NPM forward auth
// User needs to access via the authenticated proxy URL
// Web: Use OIDC flow with browser redirect
if (kIsWeb) {
developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth');
// Try to refresh auth state from /auth/me
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
state = const AsyncLoading();
final webAuth = await _tryWebAuth();
if (webAuth != null) {
state = AsyncData(webAuth);
} else {
state = AsyncError(
Exception('Not authenticated - please access via the authenticated URL'),
StackTrace.current,
);
try {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl();
developer.log('Redirecting to: $authUrl', name: 'auth');
web_utils.redirectTo(authUrl);
// Browser will redirect, so we don't update state here
} catch (e, stack) {
developer.log('Failed to start OIDC flow: $e', name: 'auth');
state = AsyncError(e, stack);
}
return;
}
// Mobile: Use OIDC flow
// Mobile: Use OIDC flow with flutter_appauth
state = const AsyncLoading();
try {
@@ -307,6 +247,73 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Handle OIDC callback after Authentik redirects back (web only).
///
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter for CSRF verification.
Future<void> handleOidcCallback(String code, String callbackState) async {
if (!kIsWeb) {
developer.log('handleOidcCallback called on non-web platform', name: 'auth');
return;
}
developer.log('Handling OIDC callback', name: 'auth');
state = const AsyncLoading();
try {
// Step 1: Exchange code for tokens
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, callbackState);
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
// Step 3: Store credentials and user data
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log(
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
} on OidcException catch (e) {
developer.log('OIDC callback failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Callback handling failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Sign out and clear stored credentials.
Future<void> signOut() async {
await _clearStoredAuth();
+212
View File
@@ -0,0 +1,212 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:crypto/crypto.dart';
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.
///
/// Uses Authorization Code flow with PKCE for secure authentication.
/// On web, we can't use flutter_appauth, so we implement the flow manually
/// using browser redirects and URL parsing.
class OidcServiceWeb implements OidcService {
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// 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.
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
Future<String> getAuthorizationUrl() async {
// Fetch OIDC discovery document
final discovery = await _fetchDiscovery();
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
// Generate state for CSRF protection
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 = {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
developer.log('Authorization URL: $uri', name: 'oidc_web');
return uri.toString();
}
/// Exchange authorization code for tokens.
///
/// Call this after the browser redirects back with the authorization code.
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter from the callback URL (verified for CSRF).
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
if (storedState == null || state != storedState) {
_clearPkceState();
throw OidcException('State mismatch - possible CSRF attack');
}
if (codeVerifier == null) {
_clearPkceState();
throw OidcException('No code verifier - flow not started properly');
}
try {
// Fetch token endpoint from discovery
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
// Exchange code for tokens
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'authorization_code',
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_clearPkceState();
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
_clearPkceState();
throw OidcException('Token exchange failed: ${e.message}');
}
}
/// 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<OidcTokens> signIn() async {
throw OidcException(
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
);
}
/// Refresh the access token using a refresh token.
@override
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'refresh_token',
'client_id': AppConfig.authClientId,
'refresh_token': refreshToken,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String? ?? refreshToken,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
throw OidcException('Token refresh failed: ${e.message}');
}
}
/// Fetch OIDC discovery document.
Future<Map<String, dynamic>> _fetchDiscovery() async {
final response = await _dio.get<Map<String, dynamic>>(
AppConfig.authDiscoveryUrl,
);
return response.data!;
}
/// Generate a random code verifier for PKCE.
String _generateCodeVerifier() {
return _generateRandomString(64);
}
/// Generate code challenge from verifier using S256.
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Generate a random string of given length.
String _generateRandomString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
.join();
}
}
+7
View File
@@ -0,0 +1,7 @@
/// Web utilities with conditional imports.
///
/// Uses stub implementation on non-web platforms.
library;
export 'web_utils_stub.dart'
if (dart.library.js_interop) 'web_utils_web.dart';
+32
View File
@@ -0,0 +1,32 @@
/// Stub for non-web platforms.
library;
/// Redirect to a URL (no-op on non-web).
void redirectTo(String url) {
throw UnsupportedError('redirectTo is only supported on web');
}
/// Get current URL (no-op on non-web).
String getCurrentUrl() {
throw UnsupportedError('getCurrentUrl is only supported on web');
}
/// Replace current URL without navigation (no-op on non-web).
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');
}
+34
View File
@@ -0,0 +1,34 @@
/// Web-specific utilities for browser operations.
library;
import 'package:web/web.dart' as web;
/// Redirect the browser to a URL.
void redirectTo(String url) {
web.window.location.href = url;
}
/// Get the current browser URL.
String getCurrentUrl() {
return web.window.location.href;
}
/// Replace the current URL in history without navigation.
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);
}
+7 -1
View File
@@ -45,12 +45,18 @@ class AppConfig {
defaultValue: 'tatlock-ui',
);
/// Authentik redirect URI scheme
/// Authentik redirect URI scheme (for mobile/native)
static const authRedirectScheme = String.fromEnvironment(
'AUTH_REDIRECT_SCHEME',
defaultValue: 'net.schweitz.tatlock',
);
/// Web app base URL (for OIDC redirect URI on web)
static const webBaseUrl = String.fromEnvironment(
'WEB_BASE_URL',
defaultValue: 'https://home.schweitz.net',
);
/// Whether running in debug mode
static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false);
+1 -5
View File
@@ -14,9 +14,5 @@ void main() {
name: 'tatlock_ui',
);
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
runApp(const ProviderScope(child: TatlockApp()));
}
+148 -30
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -18,6 +17,7 @@ abstract class AppRoutes {
static const parlor = '/parlor';
static const settings = '/settings';
static const login = '/login';
static const callback = '/callback';
}
/// Provides the GoRouter instance.
@@ -47,6 +47,11 @@ GoRouter appRouter(Ref ref) {
return AppRoutes.frontHall;
}
// Allow callback route through without auth check
if (state.matchedLocation == AppRoutes.callback) {
return null;
}
return null;
},
routes: [
@@ -56,6 +61,17 @@ GoRouter appRouter(Ref ref) {
name: 'login',
builder: (context, state) => const _LoginPage(),
),
// OIDC callback route (handles auth code exchange)
GoRoute(
path: AppRoutes.callback,
name: 'callback',
builder: (context, state) => _OidcCallbackPage(
code: state.uri.queryParameters['code'],
callbackState: state.uri.queryParameters['state'],
error: state.uri.queryParameters['error'],
errorDescription: state.uri.queryParameters['error_description'],
),
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
@@ -185,32 +201,7 @@ class _LoginPage extends ConsumerWidget {
}
Widget _buildSignInContent(BuildContext context, WidgetRef ref) {
if (kIsWeb) {
// Web: User needs to access via authenticated proxy
return Column(
children: [
Text(
'Please access Tatlock via the authenticated URL.\n'
'If you see this page, the proxy authentication may not be configured.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
);
}
// Mobile: Show sign in button
// Same sign in button for both web and mobile
return FilledButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.login),
@@ -266,9 +257,136 @@ class _LoginPage extends ConsumerWidget {
if (message.contains('network')) {
return 'Network error. Please check your connection.';
}
if (message.contains('authenticated URL')) {
return 'Not authenticated - please access via the authenticated URL';
}
return 'Authentication failed. Please try again.';
}
}
/// OIDC callback page that handles the authorization code exchange.
class _OidcCallbackPage extends ConsumerStatefulWidget {
const _OidcCallbackPage({
this.code,
this.callbackState,
this.error,
this.errorDescription,
});
final String? code;
final String? callbackState;
final String? error;
final String? errorDescription;
@override
ConsumerState<_OidcCallbackPage> createState() => _OidcCallbackPageState();
}
class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
bool _isProcessing = true;
String? _error;
@override
void initState() {
super.initState();
_processCallback();
}
Future<void> _processCallback() async {
// Check for error from Authentik
if (widget.error != null) {
setState(() {
_isProcessing = false;
_error = widget.errorDescription ?? widget.error;
});
return;
}
// Check for required parameters
if (widget.code == null || widget.callbackState == null) {
setState(() {
_isProcessing = false;
_error = 'Invalid callback - missing code or state parameter';
});
return;
}
// Exchange code for tokens
try {
await ref.read(authProvider.notifier).handleOidcCallback(
widget.code!,
widget.callbackState!,
);
// Navigate to home on success
if (mounted) {
context.go(AppRoutes.frontHall);
}
} catch (e) {
if (mounted) {
setState(() {
_isProcessing = false;
_error = e.toString();
});
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_error != null ? Icons.error_outline : Icons.home_work_outlined,
size: 64,
color: _error != null ? colorScheme.error : colorScheme.primary,
),
const SizedBox(height: 24),
Text(
_error != null ? 'Authentication Failed' : 'Signing in...',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
if (_isProcessing)
const CircularProgressIndicator()
else if (_error != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style: TextStyle(color: colorScheme.onErrorContainer),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.go(AppRoutes.login),
icon: const Icon(Icons.arrow_back),
label: const Text('Back to Login'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
],
),
),
),
),
),
);
}
}
-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 = '0.3.3';
static const int buildNumber = 1;
static const String fullVersion = '0.3.3+1';
}
+3 -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
# 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.1+1
version: 1.0.7+1
environment:
sdk: ^3.10.4
@@ -52,6 +52,8 @@ dependencies:
# Authentication (OIDC/OAuth2)
flutter_appauth: ^8.0.0
crypto: ^3.0.3
web: ^1.1.0
# UI
flex_color_scheme: ^8.1.0
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env dart
// Generates web/health.json from pubspec.yaml
// Run: dart run tool/generate_health_json.dart
import 'dart:convert';
import 'dart:io';
import 'package:yaml/yaml.dart';
void main() {
final pubspecFile = File('pubspec.yaml');
if (!pubspecFile.existsSync()) {
stderr.writeln('Error: pubspec.yaml not found');
exit(1);
}
final pubspecContent = pubspecFile.readAsStringSync();
final pubspec = loadYaml(pubspecContent) as YamlMap;
final name = pubspec['name'] as String;
final description = pubspec['description'] as String? ?? '';
final versionString = pubspec['version'] as String;
// Parse version: "1.0.3+1" -> version="1.0.3", buildNumber=1
final versionParts = versionString.split('+');
final version = versionParts[0];
final buildNumber = versionParts.length > 1 ? int.parse(versionParts[1]) : 0;
final health = {
'status': 'healthy',
'name': name,
'title': description,
'version': version,
'buildNumber': buildNumber,
'fullVersion': '$version+$buildNumber',
};
final webDir = Directory('web');
if (!webDir.existsSync()) {
webDir.createSync(recursive: true);
}
final healthFile = File('web/health.json');
healthFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(health),
);
print('Generated web/health.json with version $version+$buildNumber');
}
+25 -2
View File
@@ -1,7 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>OK</title>
<title>Health Check</title>
<style>
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
.healthy { color: #0f0; }
.error { color: #f00; }
pre { background: #222; padding: 15px; border-radius: 5px; }
</style>
</head>
<body>OK</body>
<body>
<h1 id="status">Loading...</h1>
<pre id="data"></pre>
<script>
fetch('/health.json')
.then(r => r.json())
.then(data => {
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
document.getElementById('status').className = data.status === 'healthy' ? 'healthy' : 'error';
document.getElementById('data').textContent = JSON.stringify(data, null, 2);
})
.catch(err => {
document.getElementById('status').textContent = 'ERROR';
document.getElementById('status').className = 'error';
document.getElementById('data').textContent = err.message;
});
</script>
</body>
</html>