Compare commits

...
2 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 3fa97bb0b0 feat: silent OIDC auth with JWT Bearer tokens for web
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Add prompt=none to silently obtain JWT when Authentik session exists
- Flutter sends Bearer token to core-api instead of forward auth cookies
- Fixes cross-subdomain cookie issues between home/api.schweitz.net
- Callback syncs with /auth/sync for user profile and roles
- API interceptor now adds Bearer token on web

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:11:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f0b32ff68b fix(auth): skip Flutter OIDC on web, rely on NPM forward auth
Build and Push / release (push) Successful in 4s
Build and Push / build (push) Successful in 3m9s
On web, NPM forward auth handles authentication at the proxy level.
By the time the Flutter app loads, the user is already authenticated.
Skip the redundant Flutter OIDC flow that was causing Riverpod
"Ref disposed" errors from conflicting auth state updates.

Mobile still uses Flutter's OIDC flow as before.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:01:33 +01:00
6 changed files with 100 additions and 72 deletions
+18 -7
View File
@@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.1] - 2026-01-04
## [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
@@ -15,12 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Uses OIDC end_session_endpoint from discovery document
- Redirects back to app after Authentik logout completes
### Fixed
- Fixed Riverpod lifecycle error in OIDC callback page
- "Cannot use the Ref of authProvider after it has been disposed"
- Store notifier reference before async gap to prevent disposed ref access
- Add mounted check at start of callback processing
## [1.1.0] - 2026-01-04
### Changed
+3 -10
View File
@@ -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}';
}
});
+60 -50
View File
@@ -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,44 @@ 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) {
// 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 +299,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 +328,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,35 +350,6 @@ 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.
+8 -2
View File
@@ -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();
}
+10 -2
View File
@@ -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
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.1.1+1
version: 1.1.3+1
environment:
sdk: ^3.10.4