integration, some parts works

This commit is contained in:
oskar 2026-01-09 19:47:24 +01:00
parent ca166eb661
commit 90d34aed42
19 changed files with 368 additions and 40 deletions

View file

@ -13,7 +13,7 @@ Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-care
docker compose up -d docker compose up -d
``` ```
2) Run the API: 2) Run the API (JWT resource server):
```bash ```bash
./gradlew :app:bootRun -Dspring.profiles.active=local ./gradlew :app:bootRun -Dspring.profiles.active=local
@ -25,14 +25,20 @@ docker compose up -d
./gradlew :workers:notification-worker:bootRun ./gradlew :workers:notification-worker:bootRun
``` ```
## Auth (local profile) ## Auth
For local development, add headers: - The backend is a JWT resource server and does not handle user passwords.
- `X-Local-User`: user id - Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`
- `X-Local-Tenant`: tenant id and `ALLOW_LOCAL_AUTH=true`.
Local headers (dev only):
- `X-Local-Email`: user id/email
- `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER) - `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER)
- `X-Tenant-Id`: tenant id
## OpenAPI ## OpenAPI
- http://localhost:8080/swagger-ui/index.html - http://localhost:8080/swagger-ui/index.html
## Health
- http://localhost:8080/health
## Key services ## Key services
- Postgres: localhost:5432 (mosenioring/mosenioring) - Postgres: localhost:5432 (mosenioring/mosenioring)

View file

@ -0,0 +1,14 @@
package com.mosenioring.app.api
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class HealthController {
@GetMapping("/health")
fun health(): ResponseEntity<Map<String, String>> {
return ResponseEntity.ok(mapOf("status" to "ok"))
}
}

View file

@ -23,7 +23,7 @@ class SecurityConfig {
http http
.csrf { it.disable() } .csrf { it.disable() }
.authorizeHttpRequests { .authorizeHttpRequests {
it.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
it.anyRequest().authenticated() it.anyRequest().authenticated()
} }

View file

@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Profile import org.springframework.context.annotation.Profile
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@ -12,15 +13,21 @@ import org.springframework.web.filter.OncePerRequestFilter
@Component @Component
@Profile("local") @Profile("local")
class LocalAuthFilter : OncePerRequestFilter() { class LocalAuthFilter(
@Value("\${ALLOW_LOCAL_AUTH:false}") private val allowLocalAuth: Boolean
) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
filterChain: FilterChain filterChain: FilterChain
) { ) {
val userId = request.getHeader("X-Local-User") if (!allowLocalAuth) {
val tenantId = request.getHeader("X-Local-Tenant") filterChain.doFilter(request, response)
return
}
val userId = request.getHeader("X-Local-User") ?: request.getHeader("X-Local-Email")
val tenantId = request.getHeader("X-Local-Tenant") ?: request.getHeader("X-Tenant-Id")
val rolesHeader = request.getHeader("X-Local-Roles") val rolesHeader = request.getHeader("X-Local-Roles")
if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) { if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) {
val roles = rolesHeader?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet() val roles = rolesHeader?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet()

View file

@ -5,12 +5,14 @@ import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.core.env.Environment
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
@Component @Component
class TenantFilter( class TenantFilter(
@Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String @Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String,
private val environment: Environment
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
@ -18,8 +20,13 @@ class TenantFilter(
response: HttpServletResponse, response: HttpServletResponse,
filterChain: FilterChain filterChain: FilterChain
) { ) {
val tenantId = SecurityUtils.currentTenantId() val tenantIdFromToken = SecurityUtils.currentTenantId()
?: request.getHeader(tenantHeader) val tenantIdFromHeader = if (environment.activeProfiles.contains("local")) {
request.getHeader(tenantHeader)
} else {
null
}
val tenantId = tenantIdFromToken ?: tenantIdFromHeader
TenantContext.setTenantId(tenantId) TenantContext.setTenantId(tenantId)
try { try {
filterChain.doFilter(request, response) filterChain.doFilter(request, response)

View file

@ -17,6 +17,7 @@ class ProblemDetailsAdvice {
val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST) val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST)
detail.title = "Validation failed" detail.title = "Validation failed"
detail.type = java.net.URI.create("https://httpstatuses.io/400") detail.type = java.net.URI.create("https://httpstatuses.io/400")
detail.setProperty("code", "VALIDATION_FAILED")
detail.setProperty("path", request.requestURI) detail.setProperty("path", request.requestURI)
detail.setProperty("errors", ex.bindingResult.fieldErrors.map { it.toErrorMap() }) detail.setProperty("errors", ex.bindingResult.fieldErrors.map { it.toErrorMap() })
return detail return detail
@ -25,6 +26,7 @@ class ProblemDetailsAdvice {
@ExceptionHandler(ErrorResponseException::class) @ExceptionHandler(ErrorResponseException::class)
fun handleErrorResponse(ex: ErrorResponseException, request: HttpServletRequest): ProblemDetail { fun handleErrorResponse(ex: ErrorResponseException, request: HttpServletRequest): ProblemDetail {
val detail = ex.body val detail = ex.body
detail.setProperty("code", "ERROR_RESPONSE")
detail.setProperty("path", request.requestURI) detail.setProperty("path", request.requestURI)
return detail return detail
} }
@ -35,6 +37,7 @@ class ProblemDetailsAdvice {
detail.title = "Invalid request" detail.title = "Invalid request"
detail.detail = ex.message detail.detail = ex.message
detail.type = java.net.URI.create("https://httpstatuses.io/400") detail.type = java.net.URI.create("https://httpstatuses.io/400")
detail.setProperty("code", "INVALID_REQUEST")
detail.setProperty("path", request.requestURI) detail.setProperty("path", request.requestURI)
return detail return detail
} }
@ -45,6 +48,18 @@ class ProblemDetailsAdvice {
detail.title = "Conflict" detail.title = "Conflict"
detail.detail = ex.message detail.detail = ex.message
detail.type = java.net.URI.create("https://httpstatuses.io/409") detail.type = java.net.URI.create("https://httpstatuses.io/409")
detail.setProperty("code", "CONFLICT")
detail.setProperty("path", request.requestURI)
return detail
}
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception, request: HttpServletRequest): ProblemDetail {
val detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR)
detail.title = "Unexpected error"
detail.detail = ex.message
detail.type = java.net.URI.create("https://httpstatuses.io/500")
detail.setProperty("code", "UNEXPECTED_ERROR")
detail.setProperty("path", request.requestURI) detail.setProperty("path", request.requestURI)
return detail return detail
} }

View file

@ -13,7 +13,24 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
## Configuration ## Configuration
Update `lib/src/di/providers.dart` with your API base URL. Runtime configuration is provided via `--dart-define` values.
Auth + environment flags:
- `API_BASE_URL` (default: `http://localhost:8080`)
- `USE_LOCAL_AUTH` (`true`/`false`, default: `false`)
- `LOCAL_TENANT_ID` (default: `local-tenant`)
- `LOCAL_ROLES` (default: `CAREGIVER`)
- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (default: `http://localhost:8081/realms/mosenioring`)
- `KEYCLOAK_CLIENT_ID` (default: `mosenioring-mobile`)
- `KEYCLOAK_REDIRECT_URL` (default: `com.mosenioring.app://oauth2redirect`)
Local dev behavior:
- When `USE_LOCAL_AUTH=true`, the app keeps the email/password fields and sends
`X-Local-Email`, `X-Local-Roles`, and `X-Tenant-Id` headers to the backend.
- The backend must run with `SPRING_PROFILES_ACTIVE=local` and `ALLOW_LOCAL_AUTH=true`.
Keycloak redirect URI to register:
- `com.mosenioring.app://oauth2redirect` (Android + iOS)
## Swagger/OpenAPI integration ## Swagger/OpenAPI integration
@ -29,3 +46,24 @@ When the spec is ready, generate a client and replace `ApiClient` usage:
flutter pub get flutter pub get
flutter run flutter run
``` ```
Quick start (Keycloak mode):
```sh
flutter run \
--dart-define=API_BASE_URL=http://localhost:8080 \
--dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \
--dart-define=KEYCLOAK_CLIENT_ID=mosenioring-mobile \
--dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect
```
Quick start (local auth, no Keycloak):
```sh
export SPRING_PROFILES_ACTIVE=local
export ALLOW_LOCAL_AUTH=true
flutter run \
--dart-define=API_BASE_URL=http://localhost:8080 \
--dart-define=USE_LOCAL_AUTH=true \
--dart-define=LOCAL_TENANT_ID=local-tenant \
--dart-define=LOCAL_ROLES=CAREGIVER
```

View file

@ -28,6 +28,8 @@ android {
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders["appAuthRedirectScheme"] = "com.mosenioring.app"
manifestPlaceholders["appAuthRedirectHost"] = "oauth2redirect"
} }
buildTypes { buildTypes {

View file

@ -24,6 +24,14 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="${appAuthRedirectScheme}"
android:host="${appAuthRedirectHost}"/>
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View file

@ -45,5 +45,16 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.mosenioring.app</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -1,5 +1,22 @@
class AppConfig { class AppConfig {
const AppConfig({required this.apiBaseUrl}); const AppConfig({
required this.apiBaseUrl,
required this.useLocalAuth,
required this.localTenantId,
required this.localRoles,
required this.keycloakIssuer,
required this.keycloakClientId,
required this.keycloakRedirectUrl,
});
final String apiBaseUrl; final String apiBaseUrl;
final bool useLocalAuth;
final String localTenantId;
final String localRoles;
final String keycloakIssuer;
final String keycloakClientId;
final String keycloakRedirectUrl;
String get keycloakDiscoveryUrl =>
'$keycloakIssuer/.well-known/openid-configuration';
} }

View file

@ -5,17 +5,33 @@ class ApiClient {
final Dio _dio; final Dio _dio;
Future<Response<T>> request<T>(
String path, {
String method = 'GET',
Object? data,
Map<String, dynamic>? query,
Options? options,
}) {
final requestOptions = (options ?? Options()).copyWith(method: method);
return _dio.request<T>(
path,
data: data,
queryParameters: query,
options: requestOptions,
);
}
Future<Response<T>> get<T>( Future<Response<T>> get<T>(
String path, { String path, {
Map<String, dynamic>? query, Map<String, dynamic>? query,
}) { }) {
return _dio.get<T>(path, queryParameters: query); return request<T>(path, query: query);
} }
Future<Response<T>> post<T>( Future<Response<T>> post<T>(
String path, { String path, {
Object? data, Object? data,
}) { }) {
return _dio.post<T>(path, data: data); return request<T>(path, method: 'POST', data: data);
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -12,7 +13,39 @@ import '../features/auth/presentation/auth_controller.dart';
import '../features/auth/presentation/auth_state.dart'; import '../features/auth/presentation/auth_state.dart';
final appConfigProvider = Provider<AppConfig>((ref) { final appConfigProvider = Provider<AppConfig>((ref) {
return const AppConfig(apiBaseUrl: 'https://api.example.com'); const apiBaseUrl =
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8080');
const useLocalAuth =
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
const localTenantId =
String.fromEnvironment('LOCAL_TENANT_ID', defaultValue: 'local-tenant');
const localRoles =
String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER');
const keycloakIssuer = String.fromEnvironment(
'KEYCLOAK_ISSUER',
defaultValue: '',
);
const keycloakIssuerUri = String.fromEnvironment(
'KEYCLOAK_ISSUER_URI',
defaultValue: 'http://localhost:8081/realms/mosenioring',
);
final resolvedIssuer =
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
const keycloakClientId =
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: 'mosenioring-mobile');
const keycloakRedirectUrl = String.fromEnvironment(
'KEYCLOAK_REDIRECT_URL',
defaultValue: 'com.mosenioring.app://oauth2redirect',
);
return AppConfig(
apiBaseUrl: apiBaseUrl,
useLocalAuth: useLocalAuth,
localTenantId: localTenantId,
localRoles: localRoles,
keycloakIssuer: resolvedIssuer,
keycloakClientId: keycloakClientId,
keycloakRedirectUrl: keycloakRedirectUrl,
);
}); });
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) { final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
@ -23,9 +56,14 @@ final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
return AuthLocalDataSource(ref.watch(secureStorageProvider)); return AuthLocalDataSource(ref.watch(secureStorageProvider));
}); });
final flutterAppAuthProvider = Provider<FlutterAppAuth>((ref) {
return const FlutterAppAuth();
});
final dioProvider = Provider<Dio>((ref) { final dioProvider = Provider<Dio>((ref) {
final config = ref.watch(appConfigProvider); final config = ref.watch(appConfigProvider);
final localDataSource = ref.watch(authLocalDataSourceProvider); final localDataSource = ref.watch(authLocalDataSourceProvider);
final authRemoteDataSource = ref.watch(authRemoteDataSourceProvider);
final dio = Dio( final dio = Dio(
BaseOptions( BaseOptions(
@ -38,12 +76,49 @@ final dioProvider = Provider<Dio>((ref) {
dio.interceptors.add( dio.interceptors.add(
InterceptorsWrapper( InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final token = await localDataSource.readToken(); if (config.useLocalAuth) {
if (token?.accessToken.isNotEmpty == true) { final session = await localDataSource.readLocalSession();
options.headers['Authorization'] = 'Bearer ${token!.accessToken}'; if (session != null) {
options.headers['X-Local-Email'] = session.email;
options.headers['X-Local-Roles'] = session.roles;
options.headers['X-Tenant-Id'] = session.tenantId;
}
} else {
final token = await localDataSource.readToken();
if (token?.accessToken.isNotEmpty == true) {
options.headers['Authorization'] = 'Bearer ${token!.accessToken}';
}
} }
handler.next(options); handler.next(options);
}, },
onError: (error, handler) async {
if (config.useLocalAuth) {
handler.next(error);
return;
}
final response = error.response;
final requestOptions = error.requestOptions;
if (response?.statusCode != 401 || requestOptions.extra['retried'] == true) {
handler.next(error);
return;
}
final refreshToken = (await localDataSource.readToken())?.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
handler.next(error);
return;
}
try {
final newToken =
await authRemoteDataSource.refreshToken(refreshToken: refreshToken);
await localDataSource.saveToken(newToken);
requestOptions.extra['retried'] = true;
requestOptions.headers['Authorization'] = 'Bearer ${newToken.accessToken}';
final retryResponse = await dio.fetch(requestOptions);
handler.resolve(retryResponse);
} catch (_) {
handler.next(error);
}
},
), ),
); );
@ -55,7 +130,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
}); });
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) { final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
return AuthRemoteDataSource(ref.watch(dioProvider)); return AuthRemoteDataSource(
ref.watch(flutterAppAuthProvider),
ref.watch(appConfigProvider),
);
}); });
final authRepositoryProvider = Provider<AuthRepository>((ref) { final authRepositoryProvider = Provider<AuthRepository>((ref) {

View file

@ -9,6 +9,9 @@ class AuthLocalDataSource {
static const _accessTokenKey = 'access_token'; static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token'; static const _refreshTokenKey = 'refresh_token';
static const _localEmailKey = 'local_email';
static const _localRolesKey = 'local_roles';
static const _localTenantKey = 'local_tenant';
Future<AuthToken?> readToken() async { Future<AuthToken?> readToken() async {
final accessToken = await _storage.read(key: _accessTokenKey); final accessToken = await _storage.read(key: _accessTokenKey);
@ -26,8 +29,47 @@ class AuthLocalDataSource {
} }
} }
Future<void> saveLocalSession({
required String email,
required String roles,
required String tenantId,
}) async {
await _storage.write(key: _localEmailKey, value: email);
await _storage.write(key: _localRolesKey, value: roles);
await _storage.write(key: _localTenantKey, value: tenantId);
}
Future<LocalAuthSession?> readLocalSession() async {
final email = await _storage.read(key: _localEmailKey);
final roles = await _storage.read(key: _localRolesKey);
final tenantId = await _storage.read(key: _localTenantKey);
if (email == null || tenantId == null) {
return null;
}
return LocalAuthSession(
email: email,
roles: roles?.isNotEmpty == true ? roles! : 'CAREGIVER',
tenantId: tenantId,
);
}
Future<void> clear() async { Future<void> clear() async {
await _storage.delete(key: _accessTokenKey); await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey); await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _localEmailKey);
await _storage.delete(key: _localRolesKey);
await _storage.delete(key: _localTenantKey);
} }
} }
class LocalAuthSession {
const LocalAuthSession({
required this.email,
required this.roles,
required this.tenantId,
});
final String email;
final String roles;
final String tenantId;
}

View file

@ -1,38 +1,62 @@
import 'package:dio/dio.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import '../../../core/config/app_config.dart';
import '../domain/models/auth_token.dart'; import '../domain/models/auth_token.dart';
class AuthRemoteDataSource { class AuthRemoteDataSource {
AuthRemoteDataSource(this._dio); AuthRemoteDataSource(this._appAuth, this._config);
final Dio _dio; final FlutterAppAuth _appAuth;
final AppConfig _config;
Future<AuthToken> login({ Future<AuthToken> login({
required String email, required String email,
required String password, required String password,
}) async { }) async {
final response = await _dio.post( final _ = password;
'/auth/login', final response = await _appAuth.authorizeAndExchangeCode(
data: <String, dynamic>{ AuthorizationTokenRequest(
'email': email, _config.keycloakClientId,
'password': password, _config.keycloakRedirectUrl,
}, discoveryUrl: _config.keycloakDiscoveryUrl,
loginHint: email,
promptValues: const ['login'],
scopes: const ['openid', 'profile', 'offline_access'],
),
); );
final data = response.data; final accessToken = response?.accessToken;
if (data is! Map<String, dynamic>) { if (accessToken == null || accessToken.isEmpty) {
throw Exception('Unexpected login response');
}
final accessToken =
(data['access_token'] as String?) ?? (data['token'] as String?) ?? '';
if (accessToken.isEmpty) {
throw Exception('Missing access token'); throw Exception('Missing access token');
} }
return AuthToken( return AuthToken(
accessToken: accessToken, accessToken: accessToken,
refreshToken: data['refresh_token'] as String?, refreshToken: response?.refreshToken,
);
}
Future<AuthToken> refreshToken({
required String refreshToken,
}) async {
final response = await _appAuth.token(
TokenRequest(
_config.keycloakClientId,
_config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl,
refreshToken: refreshToken,
scopes: const ['openid', 'profile', 'offline_access'],
),
);
final accessToken = response?.accessToken;
if (accessToken == null || accessToken.isEmpty) {
throw Exception('Missing access token');
}
return AuthToken(
accessToken: accessToken,
refreshToken: response?.refreshToken ?? refreshToken,
); );
} }
} }

View file

@ -1,7 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart'; import '../../../di/providers.dart';
import '../data/auth_local_data_source.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import '../domain/models/auth_token.dart';
import 'auth_state.dart'; import 'auth_state.dart';
class AuthController extends Notifier<AuthState> { class AuthController extends Notifier<AuthState> {
@ -12,9 +14,19 @@ class AuthController extends Notifier<AuthState> {
} }
AuthRepository get _repository => ref.watch(authRepositoryProvider); AuthRepository get _repository => ref.watch(authRepositoryProvider);
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
final config = ref.read(appConfigProvider);
if (config.useLocalAuth) {
final localSession = await _localDataSource.readLocalSession();
state = AuthState(
isLoading: false,
token: localSession != null ? const AuthToken(accessToken: 'local') : null,
);
return;
}
final token = await _repository.getSavedToken(); final token = await _repository.getSavedToken();
state = AuthState(isLoading: false, token: token); state = AuthState(isLoading: false, token: token);
} }
@ -25,12 +37,24 @@ class AuthController extends Notifier<AuthState> {
}) async { }) async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
try { try {
final config = ref.read(appConfigProvider);
if (config.useLocalAuth) {
await _localDataSource.saveLocalSession(
email: email,
roles: config.localRoles,
tenantId: config.localTenantId,
);
const token = AuthToken(accessToken: 'local');
await _repository.persistToken(token);
state = const AuthState(isLoading: false, token: token);
return;
}
final token = await _repository.login(email: email, password: password); final token = await _repository.login(email: email, password: password);
state = AuthState(isLoading: false, token: token); state = AuthState(isLoading: false, token: token);
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: error.toString(), errorMessage: _friendlyError(error),
); );
} }
} }
@ -39,4 +63,12 @@ class AuthController extends Notifier<AuthState> {
await _repository.logout(); await _repository.logout();
state = const AuthState(); state = const AuthState();
} }
String _friendlyError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
} }

View file

@ -42,6 +42,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider); final authState = ref.watch(authControllerProvider);
final config = ref.watch(appConfigProvider);
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
@ -99,6 +100,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
), ),
), ),
], ],
if (config.useLocalAuth) ...[
const SizedBox(height: 12),
Text(
'Local dev mode enabled',
style: Theme.of(context).textTheme.bodySmall,
),
],
], ],
), ),
), ),

View file

@ -5,10 +5,12 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import flutter_appauth
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import path_provider_foundation import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
} }

View file

@ -37,6 +37,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
dio: ^5.7.0 dio: ^5.7.0
flutter_appauth: ^11.0.0
flutter_riverpod: ^3.1.0 flutter_riverpod: ^3.1.0
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
go_router: ^17.0.1 go_router: ^17.0.1