Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa97bb0b0 |
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [1.1.2] - 2026-01-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
|
|||||||
/// Adds authentication token to requests.
|
/// Adds authentication token to requests.
|
||||||
///
|
///
|
||||||
/// - **LAN mode**: Skipped entirely (no auth required)
|
/// - **LAN mode**: Skipped entirely (no auth required)
|
||||||
/// - **Web**: Skipped (cookies handle auth via NPM forward auth)
|
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
|
||||||
/// - **Mobile**: Adds Bearer token from OIDC authentication
|
|
||||||
class AuthInterceptor extends Interceptor {
|
class AuthInterceptor extends Interceptor {
|
||||||
AuthInterceptor(this._ref);
|
AuthInterceptor(this._ref);
|
||||||
|
|
||||||
@@ -25,17 +24,11 @@ class AuthInterceptor extends Interceptor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip Bearer token on web - cookies handle auth via NPM forward auth
|
// Add Bearer token for all platforms (web + mobile)
|
||||||
if (kIsWeb) {
|
|
||||||
handler.next(options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile: Add Bearer token from OIDC authentication
|
|
||||||
final authState = _ref.read(authProvider);
|
final authState = _ref.read(authProvider);
|
||||||
|
|
||||||
authState.whenData((auth) {
|
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}';
|
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 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
@@ -40,10 +40,44 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthState> build() async {
|
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();
|
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 {
|
Future<AuthState> _loadStoredAuth() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -265,27 +299,28 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
final oidcService = OidcServiceWeb();
|
final oidcService = OidcServiceWeb();
|
||||||
final tokens = await oidcService.exchangeCode(code, callbackState);
|
final tokens = await oidcService.exchangeCode(code, callbackState);
|
||||||
|
|
||||||
// Step 2: Decode JWT to extract user info (skip core-api sync)
|
// Step 2: Sync with core-api to get user profile and roles
|
||||||
final claims = _decodeJwtClaims(tokens.accessToken);
|
developer.log('Syncing with core-api', name: 'auth');
|
||||||
final userName = claims['name'] as String? ??
|
final authDatasource = ref.read(authDatasourceProvider);
|
||||||
claims['preferred_username'] as String? ??
|
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
|
||||||
'User';
|
|
||||||
final userEmail = claims['email'] as String? ?? '';
|
|
||||||
final authentikId = claims['sub'] as String?;
|
|
||||||
final groups = (claims['groups'] as List<dynamic>?)?.cast<String>() ?? [];
|
|
||||||
|
|
||||||
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(
|
await _storeAuth(
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
expiresAt: tokens.expiresAt,
|
expiresAt: tokens.expiresAt,
|
||||||
authentikId: authentikId,
|
userId: syncResponse.userId,
|
||||||
userName: userName,
|
authentikId: syncResponse.authentikId,
|
||||||
userEmail: userEmail,
|
userName: syncResponse.name,
|
||||||
// Roles from groups - for now just store group names
|
userEmail: syncResponse.email,
|
||||||
// Full role parsing can be done later if needed
|
avatarUrl: syncResponse.avatarUrl,
|
||||||
|
roles: syncResponse.roles,
|
||||||
|
preferences: syncResponse.preferences,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = AsyncData(AuthState(
|
state = AsyncData(AuthState(
|
||||||
@@ -293,12 +328,16 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
expiresAt: tokens.expiresAt,
|
expiresAt: tokens.expiresAt,
|
||||||
authentikId: authentikId,
|
userId: syncResponse.userId,
|
||||||
userName: userName,
|
authentikId: syncResponse.authentikId,
|
||||||
userEmail: userEmail,
|
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
|
// Clean up the URL by removing the query parameters
|
||||||
web_utils.replaceUrl('/');
|
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.
|
/// Sign out and clear stored credentials.
|
||||||
///
|
///
|
||||||
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
|
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ class OidcServiceWeb implements OidcService {
|
|||||||
///
|
///
|
||||||
/// Returns a URL that the browser should navigate to for authentication.
|
/// Returns a URL that the browser should navigate to for authentication.
|
||||||
/// The [codeVerifier] and [state] are stored for later verification.
|
/// 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
|
// Fetch OIDC discovery document
|
||||||
final discovery = await _fetchDiscovery();
|
final discovery = await _fetchDiscovery();
|
||||||
final authEndpoint = discovery['authorization_endpoint'] as String;
|
final authEndpoint = discovery['authorization_endpoint'] as String;
|
||||||
@@ -59,10 +64,11 @@ class OidcServiceWeb implements OidcService {
|
|||||||
'code_challenge': codeChallenge,
|
'code_challenge': codeChallenge,
|
||||||
'code_challenge_method': 'S256',
|
'code_challenge_method': 'S256',
|
||||||
'state': state,
|
'state': state,
|
||||||
|
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
|
||||||
};
|
};
|
||||||
|
|
||||||
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
|
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();
|
return uri.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
|||||||
return _buildScaffold(context);
|
return _buildScaffold(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On web, NPM forward auth handles authentication at the proxy level.
|
// Watch auth state (works for both web and mobile)
|
||||||
// If we reach this point, the user is already authenticated by NPM.
|
|
||||||
// No need for Flutter's OIDC flow - just show the app.
|
|
||||||
if (kIsWeb) {
|
|
||||||
return _buildScaffold(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile: Use Flutter's OIDC flow
|
|
||||||
final authAsync = ref.watch(authProvider);
|
final authAsync = ref.watch(authProvider);
|
||||||
|
|
||||||
return authAsync.when(
|
return authAsync.when(
|
||||||
@@ -64,7 +57,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
|||||||
return _buildScaffold(context);
|
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) {
|
if (!_authInitiated) {
|
||||||
_authInitiated = true;
|
_authInitiated = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -75,7 +74,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
|||||||
// Show loading while redirecting to Authentik
|
// Show loading while redirecting to Authentik
|
||||||
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
|
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
|
||||||
},
|
},
|
||||||
loading: () => _buildAuthLoadingScreen(context, 'Checking authentication...'),
|
loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'),
|
||||||
error: (error, _) => _buildAuthErrorScreen(context, error),
|
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
|
# 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.1.2+1
|
version: 1.1.3+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user