Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90b4a0963 | ||
|
|
a95296e1fc | ||
|
|
3fa97bb0b0 | ||
|
|
f0b32ff68b | ||
|
|
790ae41171 | ||
|
|
8625ac6574 |
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.5] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Race condition in OIDC callback: AuthProvider.build() was initiating silent OIDC while the callback page was processing, causing PKCE state to be cleared. Now skips silent OIDC when on `/callback` route.
|
||||
|
||||
## [1.1.4] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Silent OIDC fallback: when `prompt=none` fails with `login_required` (no Authentik session), automatically fall back to regular OIDC flow to show login UI
|
||||
|
||||
## [1.1.3] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Web auth uses silent OIDC with JWT Bearer tokens**
|
||||
- Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth)
|
||||
- Flutter sends Bearer token to core-api instead of relying on forward auth cookies
|
||||
- Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net
|
||||
- Callback now syncs with `/auth/sync` to get user profile and roles from core-api
|
||||
- API interceptor now adds Bearer token on web (previously skipped)
|
||||
|
||||
## [1.1.2] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Web auth simplified**: Skip Flutter OIDC on web - NPM forward auth handles it
|
||||
- NPM authenticates at proxy level before app loads
|
||||
- No more redundant OIDC redirect after NPM auth completes
|
||||
- Fixes "Cannot use Ref after disposed" error from conflicting auth flows
|
||||
- Mobile still uses Flutter OIDC flow
|
||||
|
||||
### Added
|
||||
- Logout now redirects to Authentik to end SSO session
|
||||
- Clears local tokens AND invalidates Authentik session
|
||||
- Uses OIDC end_session_endpoint from discovery document
|
||||
- Redirects back to app after Authentik logout completes
|
||||
|
||||
## [1.1.0] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Dockerfile rebuild fix**: Added `flutter clean` before build to prevent stale cached artifacts
|
||||
- VERSION build arg added for explicit cache busting
|
||||
- Reordered build steps: clean → pub get → build_runner → health.json → flutter build
|
||||
- Ensures deployed app always matches the version in health.json
|
||||
|
||||
### Fixed
|
||||
- Replaced deprecated `dart:html` with `package:web` in iframe_view_web.dart
|
||||
- Uses `web.HTMLIFrameElement` instead of `html.IFrameElement`
|
||||
- Fixes deprecation warnings for Flutter 3.x web builds
|
||||
|
||||
## [1.0.12] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
|
||||
+12
-4
@@ -3,16 +3,24 @@ FROM ghcr.io/cirruslabs/flutter:stable AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY pubspec.yaml ./
|
||||
# VERSION arg busts cache when version changes in pubspec.yaml
|
||||
# Extract version: docker build --build-arg VERSION=$(grep '^version:' pubspec.yaml | cut -d' ' -f2) .
|
||||
ARG VERSION=0.0.0
|
||||
RUN echo "Building version: $VERSION"
|
||||
|
||||
# Get dependencies (generates pubspec.lock)
|
||||
# Copy dependency files first for better caching
|
||||
COPY pubspec.yaml pubspec.lock* ./
|
||||
|
||||
# Get dependencies
|
||||
RUN flutter pub get
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Generate code with build_runner
|
||||
# Clean any cached build artifacts to ensure fresh build
|
||||
RUN flutter clean && flutter pub get
|
||||
|
||||
# Generate code with build_runner (after clean for fresh generation)
|
||||
RUN dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# Generate health.json with version info
|
||||
|
||||
@@ -10,8 +10,7 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
|
||||
/// Adds authentication token to requests.
|
||||
///
|
||||
/// - **LAN mode**: Skipped entirely (no auth required)
|
||||
/// - **Web**: Skipped (cookies handle auth via NPM forward auth)
|
||||
/// - **Mobile**: Adds Bearer token from OIDC authentication
|
||||
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._ref);
|
||||
|
||||
@@ -25,17 +24,11 @@ class AuthInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Bearer token on web - cookies handle auth via NPM forward auth
|
||||
if (kIsWeb) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile: Add Bearer token from OIDC authentication
|
||||
// Add Bearer token for all platforms (web + mobile)
|
||||
final authState = _ref.read(authProvider);
|
||||
|
||||
authState.whenData((auth) {
|
||||
if (auth.isAuthenticated && auth.accessToken != null && auth.accessToken != 'web-session') {
|
||||
if (auth.isAuthenticated && auth.accessToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8;
|
||||
import 'dart:convert' show jsonDecode, jsonEncode;
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
@@ -40,10 +40,51 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
// Load stored auth on all platforms
|
||||
// On web with auth required, use silent OIDC to get JWT
|
||||
if (kIsWeb && AppConfig.requiresAuth) {
|
||||
// Skip silent OIDC if we're on the callback page (it will handle auth)
|
||||
final currentUrl = web_utils.getCurrentUrl();
|
||||
if (currentUrl.contains('/callback')) {
|
||||
developer.log('Web: On callback page, skipping silent OIDC', name: 'auth');
|
||||
return const AuthState();
|
||||
}
|
||||
|
||||
// First check if we have stored tokens
|
||||
final storedAuth = await _loadStoredAuth();
|
||||
if (storedAuth.isAuthenticated && !storedAuth.isTokenExpired) {
|
||||
developer.log('Web: Using stored tokens for ${storedAuth.userName}', name: 'auth');
|
||||
return storedAuth;
|
||||
}
|
||||
|
||||
// No valid tokens - initiate silent OIDC
|
||||
// NPM forward auth ensures user has Authentik session
|
||||
// prompt=none will get us a token instantly without UI
|
||||
developer.log('Web: No valid tokens, initiating silent OIDC', name: 'auth');
|
||||
_initiateSilentOidc();
|
||||
|
||||
// Return unauthenticated state - will redirect before this matters
|
||||
return const AuthState();
|
||||
}
|
||||
|
||||
// Mobile/LAN: Load stored auth from SharedPreferences
|
||||
return _loadStoredAuth();
|
||||
}
|
||||
|
||||
/// Initiate silent OIDC flow on web.
|
||||
///
|
||||
/// Uses prompt=none to get a token without showing login UI.
|
||||
/// Relies on existing Authentik session (established via NPM forward auth).
|
||||
Future<void> _initiateSilentOidc() async {
|
||||
try {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final authUrl = await oidcService.getAuthorizationUrl(silent: true);
|
||||
developer.log('Redirecting to silent OIDC: $authUrl', name: 'auth');
|
||||
web_utils.redirectTo(authUrl);
|
||||
} catch (e) {
|
||||
developer.log('Failed to initiate silent OIDC: $e', name: 'auth');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthState> _loadStoredAuth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -265,27 +306,28 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final tokens = await oidcService.exchangeCode(code, callbackState);
|
||||
|
||||
// Step 2: Decode JWT to extract user info (skip core-api sync)
|
||||
final claims = _decodeJwtClaims(tokens.accessToken);
|
||||
final userName = claims['name'] as String? ??
|
||||
claims['preferred_username'] as String? ??
|
||||
'User';
|
||||
final userEmail = claims['email'] as String? ?? '';
|
||||
final authentikId = claims['sub'] as String?;
|
||||
final groups = (claims['groups'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
// 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);
|
||||
|
||||
developer.log('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth');
|
||||
developer.log(
|
||||
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
|
||||
name: 'auth',
|
||||
);
|
||||
|
||||
// Step 3: Store credentials and user data from JWT
|
||||
// Step 3: Store credentials and user data from sync response
|
||||
await _storeAuth(
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
authentikId: authentikId,
|
||||
userName: userName,
|
||||
userEmail: userEmail,
|
||||
// Roles from groups - for now just store group names
|
||||
// Full role parsing can be done later if needed
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
);
|
||||
|
||||
state = AsyncData(AuthState(
|
||||
@@ -293,12 +335,16 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
authentikId: authentikId,
|
||||
userName: userName,
|
||||
userEmail: userEmail,
|
||||
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 $userName', name: 'auth');
|
||||
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
|
||||
|
||||
// Clean up the URL by removing the query parameters
|
||||
web_utils.replaceUrl('/');
|
||||
@@ -311,40 +357,28 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode JWT payload without verification (validation happens server-side).
|
||||
Map<String, dynamic> _decodeJwtClaims(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) {
|
||||
developer.log('Invalid JWT format', name: 'auth');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Decode the payload (second part)
|
||||
String payload = parts[1];
|
||||
// Add padding if needed for base64
|
||||
switch (payload.length % 4) {
|
||||
case 2:
|
||||
payload += '==';
|
||||
break;
|
||||
case 3:
|
||||
payload += '=';
|
||||
break;
|
||||
}
|
||||
|
||||
final decoded = utf8.decode(base64Url.decode(payload));
|
||||
return jsonDecode(decoded) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
developer.log('Failed to decode JWT: $e', name: 'auth');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out and clear stored credentials.
|
||||
///
|
||||
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
|
||||
Future<void> signOut() async {
|
||||
// Clear local storage first
|
||||
await _clearStoredAuth();
|
||||
state = const AsyncData(AuthState());
|
||||
developer.log('Signed out', name: 'auth');
|
||||
developer.log('Signed out locally', name: 'auth');
|
||||
|
||||
// On web, redirect to Authentik logout to end SSO session
|
||||
if (kIsWeb && AppConfig.requiresAuth) {
|
||||
try {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final logoutUrl = await oidcService.getLogoutUrl();
|
||||
developer.log('Redirecting to Authentik logout', name: 'auth');
|
||||
web_utils.redirectTo(logoutUrl);
|
||||
} catch (e) {
|
||||
developer.log('Failed to get logout URL: $e', name: 'auth');
|
||||
// Local logout already done, just reload to trigger re-auth
|
||||
web_utils.redirectTo('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user preferences.
|
||||
|
||||
@@ -34,7 +34,12 @@ class OidcServiceWeb implements OidcService {
|
||||
///
|
||||
/// Returns a URL that the browser should navigate to for authentication.
|
||||
/// The [codeVerifier] and [state] are stored for later verification.
|
||||
Future<String> getAuthorizationUrl() async {
|
||||
///
|
||||
/// If [silent] is true, adds `prompt=none` to skip login UI.
|
||||
/// This is used when the user already has an Authentik session (via NPM).
|
||||
/// Authentik will instantly redirect back with a code, or return an error
|
||||
/// if there's no valid session.
|
||||
Future<String> getAuthorizationUrl({bool silent = false}) async {
|
||||
// Fetch OIDC discovery document
|
||||
final discovery = await _fetchDiscovery();
|
||||
final authEndpoint = discovery['authorization_endpoint'] as String;
|
||||
@@ -59,10 +64,11 @@ class OidcServiceWeb implements OidcService {
|
||||
'code_challenge': codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'state': state,
|
||||
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
|
||||
};
|
||||
|
||||
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
|
||||
developer.log('Authorization URL: $uri', name: 'oidc_web');
|
||||
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
@@ -209,4 +215,31 @@ class OidcServiceWeb implements OidcService {
|
||||
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
|
||||
.join();
|
||||
}
|
||||
|
||||
/// Get the logout URL to redirect the browser to for SSO logout.
|
||||
///
|
||||
/// [idToken] is optional but recommended for logout verification.
|
||||
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
|
||||
Future<String> getLogoutUrl({String? idToken}) async {
|
||||
final discovery = await _fetchDiscovery();
|
||||
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
|
||||
|
||||
if (endSessionEndpoint == null) {
|
||||
// Fallback: just redirect to home, local state already cleared
|
||||
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
|
||||
return AppConfig.webBaseUrl;
|
||||
}
|
||||
|
||||
final params = <String, String>{
|
||||
'post_logout_redirect_uri': AppConfig.webBaseUrl,
|
||||
};
|
||||
|
||||
if (idToken != null) {
|
||||
params['id_token_hint'] = idToken;
|
||||
}
|
||||
|
||||
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
|
||||
developer.log('Logout URL: $uri', name: 'oidc_web');
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:html' as html;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
/// Embedded iframe view for displaying external content (web only).
|
||||
///
|
||||
@@ -31,7 +31,7 @@ class IframeView extends StatefulWidget {
|
||||
|
||||
class _IframeViewState extends State<IframeView> {
|
||||
late final String _viewType;
|
||||
late html.IFrameElement _iframe;
|
||||
late web.HTMLIFrameElement _iframe;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
@@ -42,17 +42,18 @@ class _IframeViewState extends State<IframeView> {
|
||||
}
|
||||
|
||||
void _createIframe() {
|
||||
_iframe = html.IFrameElement()
|
||||
_iframe = web.document.createElement('iframe') as web.HTMLIFrameElement
|
||||
..src = widget.url
|
||||
..style.border = 'none'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..allow = 'fullscreen'
|
||||
..onLoad.listen((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
});
|
||||
..allow = 'fullscreen';
|
||||
|
||||
_iframe.onLoad.listen((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
});
|
||||
|
||||
// Register the view factory
|
||||
ui_web.platformViewRegistry.registerViewFactory(
|
||||
|
||||
@@ -132,8 +132,18 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
|
||||
}
|
||||
|
||||
Future<void> _processCallback() async {
|
||||
// Check mounted before any async work
|
||||
if (!mounted) return;
|
||||
|
||||
// Check for error from Authentik
|
||||
if (widget.error != null) {
|
||||
// Silent OIDC (prompt=none) failed - no existing session
|
||||
// Fall back to regular OIDC flow to show login UI
|
||||
if (widget.error == 'login_required') {
|
||||
ref.read(authProvider.notifier).signIn();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_error = widget.errorDescription ?? widget.error;
|
||||
@@ -150,12 +160,15 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get notifier reference before async gap to avoid disposed ref errors
|
||||
final authNotifier = ref.read(authProvider.notifier);
|
||||
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
await ref.read(authProvider.notifier).handleOidcCallback(
|
||||
widget.code!,
|
||||
widget.callbackState!,
|
||||
);
|
||||
await authNotifier.handleOidcCallback(
|
||||
widget.code!,
|
||||
widget.callbackState!,
|
||||
);
|
||||
|
||||
// Navigate to home on success
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -45,6 +46,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
||||
return _buildScaffold(context);
|
||||
}
|
||||
|
||||
// Watch auth state (works for both web and mobile)
|
||||
final authAsync = ref.watch(authProvider);
|
||||
|
||||
return authAsync.when(
|
||||
@@ -55,7 +57,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
||||
return _buildScaffold(context);
|
||||
}
|
||||
|
||||
// Not authenticated - auto-initiate OIDC
|
||||
// On web, NPM handles auth - if we're here without auth, something is wrong
|
||||
// (NPM should have redirected to Authentik before we loaded)
|
||||
if (kIsWeb) {
|
||||
return _buildAuthErrorScreen(context, 'Authentication required');
|
||||
}
|
||||
|
||||
// Mobile: Not authenticated - auto-initiate OIDC
|
||||
if (!_authInitiated) {
|
||||
_authInitiated = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -66,7 +74,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
||||
// Show loading while redirecting to Authentik
|
||||
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
|
||||
},
|
||||
loading: () => _buildAuthLoadingScreen(context, 'Checking authentication...'),
|
||||
loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'),
|
||||
error: (error, _) => _buildAuthErrorScreen(context, error),
|
||||
);
|
||||
}
|
||||
|
||||
+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
|
||||
# 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.12+1
|
||||
version: 1.1.5+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Generates web/health.json from pubspec.yaml
|
||||
// Run: dart run tool/generate_health_json.dart
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user