integration, some parts works
This commit is contained in:
parent
ca166eb661
commit
90d34aed42
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue