Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35f495537 | ||
|
|
346ca75d68 | ||
|
|
0c27c10a2c | ||
|
|
ff5df30c53 |
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.11] - 2026-01-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Web auth now extracts user info directly from JWT instead of syncing with core-api
|
||||||
|
- Eliminates CORS preflight issues with /auth/sync endpoint
|
||||||
|
- Decodes JWT claims (name, email, groups) client-side
|
||||||
|
- Bearer token will be used for API authentication
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed OIDC callback route being redirected to login before processing
|
||||||
|
- Moved callback route exception check BEFORE the auth redirect check in router
|
||||||
|
- This was preventing token exchange from ever happening
|
||||||
|
- Added favicon.ico to web root for proper browser tab icon display
|
||||||
|
|
||||||
|
## [1.0.9] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed OIDC callback Riverpod state modification error
|
||||||
|
- Deferred callback processing to `addPostFrameCallback` to avoid modifying state during widget build
|
||||||
|
|
||||||
|
## [1.0.8] - 2026-01-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Switched from hash-based URLs (`/#/login`) to path-based URLs (`/login`)
|
||||||
|
- Required for OIDC callback to work correctly
|
||||||
|
- Uses conditional import to avoid breaking mobile/desktop builds
|
||||||
|
|
||||||
## [1.0.7] - 2026-01-04
|
## [1.0.7] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8;
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
@@ -265,23 +265,27 @@ 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: Sync with core-api to get user profile and roles
|
// Step 2: Decode JWT to extract user info (skip core-api sync)
|
||||||
developer.log('Syncing with core-api', name: 'auth');
|
final claims = _decodeJwtClaims(tokens.accessToken);
|
||||||
final authDatasource = ref.read(authDatasourceProvider);
|
final userName = claims['name'] as String? ??
|
||||||
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
|
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 3: Store credentials and user data
|
developer.log('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth');
|
||||||
|
|
||||||
|
// Step 3: Store credentials and user data from JWT
|
||||||
await _storeAuth(
|
await _storeAuth(
|
||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
expiresAt: tokens.expiresAt,
|
expiresAt: tokens.expiresAt,
|
||||||
userId: syncResponse.userId,
|
authentikId: authentikId,
|
||||||
authentikId: syncResponse.authentikId,
|
userName: userName,
|
||||||
userName: syncResponse.name,
|
userEmail: userEmail,
|
||||||
userEmail: syncResponse.email,
|
// Roles from groups - for now just store group names
|
||||||
avatarUrl: syncResponse.avatarUrl,
|
// Full role parsing can be done later if needed
|
||||||
roles: syncResponse.roles,
|
|
||||||
preferences: syncResponse.preferences,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
state = AsyncData(AuthState(
|
state = AsyncData(AuthState(
|
||||||
@@ -289,19 +293,12 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
accessToken: tokens.accessToken,
|
accessToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
expiresAt: tokens.expiresAt,
|
expiresAt: tokens.expiresAt,
|
||||||
userId: syncResponse.userId,
|
authentikId: authentikId,
|
||||||
authentikId: syncResponse.authentikId,
|
userName: userName,
|
||||||
userName: syncResponse.name,
|
userEmail: userEmail,
|
||||||
userEmail: syncResponse.email,
|
|
||||||
avatarUrl: syncResponse.avatarUrl,
|
|
||||||
roles: syncResponse.roles,
|
|
||||||
preferences: syncResponse.preferences,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
developer.log(
|
developer.log('Authenticated as $userName', name: 'auth');
|
||||||
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
|
|
||||||
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('/');
|
||||||
@@ -314,6 +311,35 @@ 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.
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
await _clearStoredAuth();
|
await _clearStoredAuth();
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/// URL strategy with conditional imports for web/non-web platforms.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'url_strategy_stub.dart'
|
||||||
|
if (dart.library.js_interop) 'url_strategy_web.dart';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/// Stub for non-web platforms - does nothing.
|
||||||
|
void configureUrlStrategy() {
|
||||||
|
// No-op on mobile/desktop
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// Web-specific URL strategy configuration.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
|
void configureUrlStrategy() {
|
||||||
|
// Use path-based URLs instead of hash-based (e.g., /login instead of /#/login)
|
||||||
|
// Required for OIDC callback to work properly
|
||||||
|
usePathUrlStrategy();
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/config/url_strategy.dart';
|
||||||
import 'version.g.dart';
|
import 'version.g.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Use path-based URLs on web (no-op on mobile/desktop)
|
||||||
|
configureUrlStrategy();
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'${AppVersion.name} v${AppVersion.fullVersion}',
|
'${AppVersion.name} v${AppVersion.fullVersion}',
|
||||||
name: 'tatlock_ui',
|
name: 'tatlock_ui',
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ GoRouter appRouter(Ref ref) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow callback route through without auth check (must be checked FIRST!)
|
||||||
|
if (state.matchedLocation == AppRoutes.callback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final isAuthenticated = authState.value?.isAuthenticated ?? false;
|
final isAuthenticated = authState.value?.isAuthenticated ?? false;
|
||||||
final isLoginRoute = state.matchedLocation == AppRoutes.login;
|
final isLoginRoute = state.matchedLocation == AppRoutes.login;
|
||||||
|
|
||||||
@@ -47,11 +52,6 @@ GoRouter appRouter(Ref ref) {
|
|||||||
return AppRoutes.frontHall;
|
return AppRoutes.frontHall;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow callback route through without auth check
|
|
||||||
if (state.matchedLocation == AppRoutes.callback) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -286,7 +286,10 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_processCallback();
|
// Defer callback processing to avoid Riverpod state modification during build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_processCallback();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processCallback() async {
|
Future<void> _processCallback() async {
|
||||||
|
|||||||
+3
-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.0.7+1
|
version: 1.0.11+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
@@ -30,6 +30,8 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.0.0
|
flutter_riverpod: ^3.0.0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -27,6 +27,7 @@
|
|||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>Tatlock</title>
|
<title>Tatlock</title>
|
||||||
|
|||||||
Reference in New Issue
Block a user