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
```
2) Run the API:
2) Run the API (JWT resource server):
```bash
./gradlew :app:bootRun -Dspring.profiles.active=local
@ -25,14 +25,20 @@ docker compose up -d
./gradlew :workers:notification-worker:bootRun
```
## Auth (local profile)
For local development, add headers:
- `X-Local-User`: user id
- `X-Local-Tenant`: tenant id
## Auth
- The backend is a JWT resource server and does not handle user passwords.
- Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`
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-Tenant-Id`: tenant id
## OpenAPI
- http://localhost:8080/swagger-ui/index.html
## Health
- http://localhost:8080/health
## Key services
- 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
.csrf { it.disable() }
.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.anyRequest().authenticated()
}

View file

@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Profile
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
@ -12,15 +13,21 @@ import org.springframework.web.filter.OncePerRequestFilter
@Component
@Profile("local")
class LocalAuthFilter : OncePerRequestFilter() {
class LocalAuthFilter(
@Value("\${ALLOW_LOCAL_AUTH:false}") private val allowLocalAuth: Boolean
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val userId = request.getHeader("X-Local-User")
val tenantId = request.getHeader("X-Local-Tenant")
if (!allowLocalAuth) {
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")
if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) {
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.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.env.Environment
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
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() {
override fun doFilterInternal(
@ -18,8 +20,13 @@ class TenantFilter(
response: HttpServletResponse,
filterChain: FilterChain
) {
val tenantId = SecurityUtils.currentTenantId()
?: request.getHeader(tenantHeader)
val tenantIdFromToken = SecurityUtils.currentTenantId()
val tenantIdFromHeader = if (environment.activeProfiles.contains("local")) {
request.getHeader(tenantHeader)
} else {
null
}
val tenantId = tenantIdFromToken ?: tenantIdFromHeader
TenantContext.setTenantId(tenantId)
try {
filterChain.doFilter(request, response)

View file

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

View file

@ -13,7 +13,24 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
## 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
@ -29,3 +46,24 @@ When the spec is ready, generate a client and replace `ApiClient` usage:
flutter pub get
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
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["appAuthRedirectScheme"] = "com.mosenioring.app"
manifestPlaceholders["appAuthRedirectHost"] = "oauth2redirect"
}
buildTypes {

View file

@ -24,6 +24,14 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</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>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View file

@ -45,5 +45,16 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<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>
</plist>

View file

@ -1,5 +1,22 @@
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 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;
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>(
String path, {
Map<String, dynamic>? query,
}) {
return _dio.get<T>(path, queryParameters: query);
return request<T>(path, query: query);
}
Future<Response<T>> post<T>(
String path, {
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:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
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) {
@ -23,9 +56,14 @@ final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
return AuthLocalDataSource(ref.watch(secureStorageProvider));
});
final flutterAppAuthProvider = Provider<FlutterAppAuth>((ref) {
return const FlutterAppAuth();
});
final dioProvider = Provider<Dio>((ref) {
final config = ref.watch(appConfigProvider);
final localDataSource = ref.watch(authLocalDataSourceProvider);
final authRemoteDataSource = ref.watch(authRemoteDataSourceProvider);
final dio = Dio(
BaseOptions(
@ -38,12 +76,49 @@ final dioProvider = Provider<Dio>((ref) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
if (config.useLocalAuth) {
final session = await localDataSource.readLocalSession();
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);
},
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) {
return AuthRemoteDataSource(ref.watch(dioProvider));
return AuthRemoteDataSource(
ref.watch(flutterAppAuthProvider),
ref.watch(appConfigProvider),
);
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {

View file

@ -9,6 +9,9 @@ class AuthLocalDataSource {
static const _accessTokenKey = 'access_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 {
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 {
await _storage.delete(key: _accessTokenKey);
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';
class AuthRemoteDataSource {
AuthRemoteDataSource(this._dio);
AuthRemoteDataSource(this._appAuth, this._config);
final Dio _dio;
final FlutterAppAuth _appAuth;
final AppConfig _config;
Future<AuthToken> login({
required String email,
required String password,
}) async {
final response = await _dio.post(
'/auth/login',
data: <String, dynamic>{
'email': email,
'password': password,
},
final _ = password;
final response = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
_config.keycloakClientId,
_config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl,
loginHint: email,
promptValues: const ['login'],
scopes: const ['openid', 'profile', 'offline_access'],
),
);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw Exception('Unexpected login response');
}
final accessToken =
(data['access_token'] as String?) ?? (data['token'] as String?) ?? '';
if (accessToken.isEmpty) {
final accessToken = response?.accessToken;
if (accessToken == null || accessToken.isEmpty) {
throw Exception('Missing access token');
}
return AuthToken(
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 '../../../di/providers.dart';
import '../data/auth_local_data_source.dart';
import '../domain/auth_repository.dart';
import '../domain/models/auth_token.dart';
import 'auth_state.dart';
class AuthController extends Notifier<AuthState> {
@ -12,9 +14,19 @@ class AuthController extends Notifier<AuthState> {
}
AuthRepository get _repository => ref.watch(authRepositoryProvider);
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
Future<void> _bootstrap() async {
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();
state = AuthState(isLoading: false, token: token);
}
@ -25,12 +37,24 @@ class AuthController extends Notifier<AuthState> {
}) async {
state = state.copyWith(isLoading: true, errorMessage: null);
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);
state = AuthState(isLoading: false, token: token);
} catch (error) {
state = state.copyWith(
isLoading: false,
errorMessage: error.toString(),
errorMessage: _friendlyError(error),
);
}
}
@ -39,4 +63,12 @@ class AuthController extends Notifier<AuthState> {
await _repository.logout();
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
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final config = ref.watch(appConfigProvider);
final l10n = AppLocalizations.of(context)!;
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 Foundation
import flutter_appauth
import flutter_secure_storage_darwin
import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
}

View file

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