@rashidee/co2 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- package/static/index.html +24 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
# Security Patterns — OAuth2 PKCE (Keycloak via flutter_appauth) and Local JWT
|
|
2
|
+
|
|
3
|
+
This reference describes the complete authentication architecture for the spec. Include
|
|
4
|
+
all of this content in Section 6 of the generated specification.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Architecture Overview — Keycloak PKCE (Auth = Keycloak)
|
|
9
|
+
|
|
10
|
+
The Flutter app operates as an OAuth2 Public Client using Authorization Code flow with
|
|
11
|
+
PKCE (Proof Key for Code Exchange) and OIDC, via the `flutter_appauth` package which
|
|
12
|
+
wraps the native AppAuth libraries (Android + iOS). Keycloak is the external identity
|
|
13
|
+
provider that handles login UI, user registration, and role management.
|
|
14
|
+
|
|
15
|
+
Responsibilities:
|
|
16
|
+
|
|
17
|
+
1. Open the system browser (Chrome Custom Tabs / SFAuthenticationSession) for login
|
|
18
|
+
2. Handle the OAuth2 callback via a registered custom URL scheme
|
|
19
|
+
3. Exchange the authorization code for ID token + access token + refresh token (PKCE)
|
|
20
|
+
4. Store tokens securely in `flutter_secure_storage` (Keychain on iOS, EncryptedSharedPreferences on Android)
|
|
21
|
+
5. Extract roles from OIDC ID token claims (`realm_access.roles`, `resource_access.<client>.roles`)
|
|
22
|
+
6. Attach Bearer token to all Dio requests via interceptor
|
|
23
|
+
7. Refresh tokens silently in the background or on 401 responses
|
|
24
|
+
|
|
25
|
+
The flow:
|
|
26
|
+
```
|
|
27
|
+
User navigates to protected route
|
|
28
|
+
→ go_router redirect detects unauthenticated state
|
|
29
|
+
→ AuthRepository.signIn() invokes FlutterAppAuth.authorizeAndExchangeCode(...)
|
|
30
|
+
→ System browser opens Keycloak /auth?response_type=code&code_challenge=...
|
|
31
|
+
→ User logs in at Keycloak
|
|
32
|
+
→ Keycloak redirects to {{APPLICATION_ID}}://oauth2redirect?code=...
|
|
33
|
+
→ Native AppAuth handler completes PKCE exchange (ID + access + refresh tokens)
|
|
34
|
+
→ Tokens persisted to flutter_secure_storage
|
|
35
|
+
→ AuthNotifier emits AuthState.authenticated(user, accessToken)
|
|
36
|
+
→ go_router rebuilds, user lands on the protected route
|
|
37
|
+
→ Dio interceptor reads token from secure storage and attaches Bearer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## URL Scheme Registration
|
|
43
|
+
|
|
44
|
+
### Android (`android/app/src/main/AndroidManifest.xml`)
|
|
45
|
+
|
|
46
|
+
Inside the `<activity>` element for `MainActivity`:
|
|
47
|
+
|
|
48
|
+
```xml
|
|
49
|
+
<intent-filter android:autoVerify="true">
|
|
50
|
+
<action android:name="android.intent.action.VIEW"/>
|
|
51
|
+
<category android:name="android.intent.category.DEFAULT"/>
|
|
52
|
+
<category android:name="android.intent.category.BROWSABLE"/>
|
|
53
|
+
<data android:scheme="{{APPLICATION_ID}}"/>
|
|
54
|
+
</intent-filter>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Also add the AppAuth redirect scheme placeholder to `android/app/build.gradle`:
|
|
58
|
+
|
|
59
|
+
```groovy
|
|
60
|
+
android {
|
|
61
|
+
defaultConfig {
|
|
62
|
+
manifestPlaceholders = [
|
|
63
|
+
'appAuthRedirectScheme': '{{APPLICATION_ID}}'
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### iOS (`ios/Runner/Info.plist`)
|
|
70
|
+
|
|
71
|
+
```xml
|
|
72
|
+
<key>CFBundleURLTypes</key>
|
|
73
|
+
<array>
|
|
74
|
+
<dict>
|
|
75
|
+
<key>CFBundleURLName</key>
|
|
76
|
+
<string>{{APPLICATION_ID}}</string>
|
|
77
|
+
<key>CFBundleURLSchemes</key>
|
|
78
|
+
<array>
|
|
79
|
+
<string>{{APPLICATION_ID}}</string>
|
|
80
|
+
</array>
|
|
81
|
+
</dict>
|
|
82
|
+
</array>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## AuthRepository (Keycloak)
|
|
88
|
+
|
|
89
|
+
```dart
|
|
90
|
+
// lib/core/auth/auth_repository.dart
|
|
91
|
+
import 'package:flutter_appauth/flutter_appauth.dart';
|
|
92
|
+
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
93
|
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
94
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
95
|
+
|
|
96
|
+
import 'auth_state.dart';
|
|
97
|
+
import 'jwt_decoder.dart';
|
|
98
|
+
|
|
99
|
+
part 'auth_repository.g.dart';
|
|
100
|
+
|
|
101
|
+
class AuthRepository {
|
|
102
|
+
AuthRepository(this._secureStorage) : _appAuth = const FlutterAppAuth();
|
|
103
|
+
|
|
104
|
+
final FlutterSecureStorage _secureStorage;
|
|
105
|
+
final FlutterAppAuth _appAuth;
|
|
106
|
+
|
|
107
|
+
static const _accessTokenKey = 'auth_access_token';
|
|
108
|
+
static const _refreshTokenKey = 'auth_refresh_token';
|
|
109
|
+
static const _idTokenKey = 'auth_id_token';
|
|
110
|
+
|
|
111
|
+
String get _issuer => dotenv.env['KEYCLOAK_ISSUER']!;
|
|
112
|
+
String get _clientId => dotenv.env['KEYCLOAK_CLIENT_ID']!;
|
|
113
|
+
String get _redirectUri => dotenv.env['KEYCLOAK_REDIRECT_URI']!;
|
|
114
|
+
String get _postLogoutRedirectUri => dotenv.env['KEYCLOAK_POST_LOGOUT_REDIRECT_URI']!;
|
|
115
|
+
|
|
116
|
+
/// Triggers the system browser flow and returns the authenticated state.
|
|
117
|
+
Future<AuthState> signIn() async {
|
|
118
|
+
final result = await _appAuth.authorizeAndExchangeCode(
|
|
119
|
+
AuthorizationTokenRequest(
|
|
120
|
+
_clientId,
|
|
121
|
+
_redirectUri,
|
|
122
|
+
issuer: _issuer,
|
|
123
|
+
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
|
124
|
+
promptValues: const ['login'],
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await _persistTokens(result);
|
|
129
|
+
return _stateFromTokens(result.accessToken!, result.idToken!);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Restores session at app startup, refreshing the token if needed.
|
|
133
|
+
Future<AuthState?> restoreSession() async {
|
|
134
|
+
final accessToken = await _secureStorage.read(key: _accessTokenKey);
|
|
135
|
+
final refreshToken = await _secureStorage.read(key: _refreshTokenKey);
|
|
136
|
+
final idToken = await _secureStorage.read(key: _idTokenKey);
|
|
137
|
+
|
|
138
|
+
if (refreshToken == null) return null;
|
|
139
|
+
|
|
140
|
+
// If access token is still valid, use it directly
|
|
141
|
+
if (accessToken != null && idToken != null && !JwtDecoder.isExpired(accessToken)) {
|
|
142
|
+
return _stateFromTokens(accessToken, idToken);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Otherwise refresh
|
|
146
|
+
try {
|
|
147
|
+
final result = await _appAuth.token(
|
|
148
|
+
TokenRequest(
|
|
149
|
+
_clientId,
|
|
150
|
+
_redirectUri,
|
|
151
|
+
issuer: _issuer,
|
|
152
|
+
refreshToken: refreshToken,
|
|
153
|
+
scopes: const ['openid', 'profile', 'email', 'offline_access'],
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
await _persistTokens(result);
|
|
157
|
+
return _stateFromTokens(result.accessToken!, result.idToken!);
|
|
158
|
+
} catch (_) {
|
|
159
|
+
await signOutLocal();
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Future<String?> getAccessToken() async {
|
|
165
|
+
final token = await _secureStorage.read(key: _accessTokenKey);
|
|
166
|
+
if (token == null) return null;
|
|
167
|
+
if (JwtDecoder.isExpired(token)) {
|
|
168
|
+
final restored = await restoreSession();
|
|
169
|
+
if (restored is _Authenticated) return restored.accessToken;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return token;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
Future<void> signOut() async {
|
|
176
|
+
final idToken = await _secureStorage.read(key: _idTokenKey);
|
|
177
|
+
if (idToken != null) {
|
|
178
|
+
await _appAuth.endSession(
|
|
179
|
+
EndSessionRequest(
|
|
180
|
+
idTokenHint: idToken,
|
|
181
|
+
postLogoutRedirectUrl: _postLogoutRedirectUri,
|
|
182
|
+
issuer: _issuer,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
await signOutLocal();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
Future<void> signOutLocal() async {
|
|
190
|
+
await _secureStorage.delete(key: _accessTokenKey);
|
|
191
|
+
await _secureStorage.delete(key: _refreshTokenKey);
|
|
192
|
+
await _secureStorage.delete(key: _idTokenKey);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
Future<void> _persistTokens(TokenResponse result) async {
|
|
196
|
+
await _secureStorage.write(key: _accessTokenKey, value: result.accessToken);
|
|
197
|
+
if (result.refreshToken != null) {
|
|
198
|
+
await _secureStorage.write(key: _refreshTokenKey, value: result.refreshToken);
|
|
199
|
+
}
|
|
200
|
+
if (result.idToken != null) {
|
|
201
|
+
await _secureStorage.write(key: _idTokenKey, value: result.idToken);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
AuthState _stateFromTokens(String accessToken, String idToken) {
|
|
206
|
+
final claims = JwtDecoder.decode(idToken);
|
|
207
|
+
final user = AuthUser(
|
|
208
|
+
id: claims['sub'] as String? ?? '',
|
|
209
|
+
username: claims['preferred_username'] as String? ?? '',
|
|
210
|
+
email: claims['email'] as String? ?? '',
|
|
211
|
+
name: claims['name'] as String? ?? '',
|
|
212
|
+
roles: _extractRoles(claims),
|
|
213
|
+
);
|
|
214
|
+
return AuthState.authenticated(user: user, accessToken: accessToken);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
List<String> _extractRoles(Map<String, dynamic> claims) {
|
|
218
|
+
final roles = <String>{};
|
|
219
|
+
final realmAccess = claims['realm_access'] as Map<String, dynamic>?;
|
|
220
|
+
if (realmAccess != null) {
|
|
221
|
+
final realmRoles = (realmAccess['roles'] as List?)?.cast<String>() ?? const [];
|
|
222
|
+
roles.addAll(realmRoles);
|
|
223
|
+
}
|
|
224
|
+
final resourceAccess = claims['resource_access'] as Map<String, dynamic>?;
|
|
225
|
+
if (resourceAccess != null) {
|
|
226
|
+
final clientAccess = resourceAccess[_clientId] as Map<String, dynamic>?;
|
|
227
|
+
if (clientAccess != null) {
|
|
228
|
+
final clientRoles = (clientAccess['roles'] as List?)?.cast<String>() ?? const [];
|
|
229
|
+
roles.addAll(clientRoles);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return roles.map((r) => r.toUpperCase()).toList();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@Riverpod(keepAlive: true)
|
|
237
|
+
AuthRepository authRepository(AuthRepositoryRef ref) {
|
|
238
|
+
return AuthRepository(const FlutterSecureStorage());
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Lightweight JWT Decoder
|
|
243
|
+
|
|
244
|
+
```dart
|
|
245
|
+
// lib/core/auth/jwt_decoder.dart
|
|
246
|
+
import 'dart:convert';
|
|
247
|
+
|
|
248
|
+
class JwtDecoder {
|
|
249
|
+
JwtDecoder._();
|
|
250
|
+
|
|
251
|
+
static Map<String, dynamic> decode(String token) {
|
|
252
|
+
final parts = token.split('.');
|
|
253
|
+
if (parts.length != 3) throw const FormatException('Invalid JWT');
|
|
254
|
+
final payload = parts[1];
|
|
255
|
+
final normalised = base64Url.normalize(payload);
|
|
256
|
+
final decoded = utf8.decode(base64Url.decode(normalised));
|
|
257
|
+
return json.decode(decoded) as Map<String, dynamic>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
static bool isExpired(String token) {
|
|
261
|
+
final claims = decode(token);
|
|
262
|
+
final exp = claims['exp'];
|
|
263
|
+
if (exp is! int) return true;
|
|
264
|
+
final expiry = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
|
265
|
+
// 30-second skew so we don't ride right to the wire
|
|
266
|
+
return DateTime.now().isAfter(expiry.subtract(const Duration(seconds: 30)));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## AuthState (freezed)
|
|
274
|
+
|
|
275
|
+
```dart
|
|
276
|
+
// lib/core/auth/auth_state.dart
|
|
277
|
+
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
278
|
+
|
|
279
|
+
part 'auth_state.freezed.dart';
|
|
280
|
+
|
|
281
|
+
@freezed
|
|
282
|
+
class AuthUser with _$AuthUser {
|
|
283
|
+
const factory AuthUser({
|
|
284
|
+
required String id,
|
|
285
|
+
required String username,
|
|
286
|
+
required String email,
|
|
287
|
+
required String name,
|
|
288
|
+
required List<String> roles,
|
|
289
|
+
}) = _AuthUser;
|
|
290
|
+
|
|
291
|
+
const AuthUser._();
|
|
292
|
+
|
|
293
|
+
bool hasRole(String role) => roles.contains(role.toUpperCase());
|
|
294
|
+
bool hasAnyRole(List<String> required) =>
|
|
295
|
+
required.any((r) => roles.contains(r.toUpperCase()));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@freezed
|
|
299
|
+
sealed class AuthState with _$AuthState {
|
|
300
|
+
const factory AuthState.unauthenticated() = _Unauthenticated;
|
|
301
|
+
const factory AuthState.authenticated({
|
|
302
|
+
required AuthUser user,
|
|
303
|
+
required String accessToken,
|
|
304
|
+
}) = _Authenticated;
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## AuthProvider (Riverpod)
|
|
311
|
+
|
|
312
|
+
```dart
|
|
313
|
+
// lib/core/auth/auth_provider.dart
|
|
314
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
315
|
+
|
|
316
|
+
import 'auth_repository.dart';
|
|
317
|
+
import 'auth_state.dart';
|
|
318
|
+
|
|
319
|
+
part 'auth_provider.g.dart';
|
|
320
|
+
|
|
321
|
+
@Riverpod(keepAlive: true)
|
|
322
|
+
class Auth extends _$Auth {
|
|
323
|
+
@override
|
|
324
|
+
FutureOr<AuthState> build() async {
|
|
325
|
+
final repo = ref.watch(authRepositoryProvider);
|
|
326
|
+
final restored = await repo.restoreSession();
|
|
327
|
+
return restored ?? const AuthState.unauthenticated();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/// Triggers the Keycloak system-browser sign-in flow.
|
|
331
|
+
Future<void> signIn() async {
|
|
332
|
+
state = const AsyncLoading();
|
|
333
|
+
state = await AsyncValue.guard(() async {
|
|
334
|
+
final repo = ref.read(authRepositoryProvider);
|
|
335
|
+
return repo.signIn();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
Future<void> signOut() async {
|
|
340
|
+
final repo = ref.read(authRepositoryProvider);
|
|
341
|
+
await repo.signOut();
|
|
342
|
+
state = const AsyncData(AuthState.unauthenticated());
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Role Constants
|
|
350
|
+
|
|
351
|
+
```dart
|
|
352
|
+
// lib/core/auth/roles.dart
|
|
353
|
+
class Roles {
|
|
354
|
+
Roles._();
|
|
355
|
+
|
|
356
|
+
// Replace with actual roles from mockup role folders / Keycloak realm.
|
|
357
|
+
static const String admin = 'ADMIN';
|
|
358
|
+
static const String worker = 'WORKER';
|
|
359
|
+
static const String user = 'USER';
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Auth Interceptor (Dio)
|
|
366
|
+
|
|
367
|
+
```dart
|
|
368
|
+
// lib/core/api/auth_interceptor.dart
|
|
369
|
+
import 'package:dio/dio.dart';
|
|
370
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
371
|
+
|
|
372
|
+
import '../auth/auth_provider.dart';
|
|
373
|
+
import '../auth/auth_repository.dart';
|
|
374
|
+
import '../auth/auth_state.dart';
|
|
375
|
+
|
|
376
|
+
class AuthInterceptor extends Interceptor {
|
|
377
|
+
AuthInterceptor(this._ref);
|
|
378
|
+
final Ref _ref;
|
|
379
|
+
|
|
380
|
+
@override
|
|
381
|
+
Future<void> onRequest(
|
|
382
|
+
RequestOptions options,
|
|
383
|
+
RequestInterceptorHandler handler,
|
|
384
|
+
) async {
|
|
385
|
+
final repo = _ref.read(authRepositoryProvider);
|
|
386
|
+
final token = await repo.getAccessToken();
|
|
387
|
+
if (token != null) {
|
|
388
|
+
options.headers['Authorization'] = 'Bearer $token';
|
|
389
|
+
}
|
|
390
|
+
handler.next(options);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
@override
|
|
394
|
+
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
|
395
|
+
if (err.response?.statusCode == 401) {
|
|
396
|
+
// Force refresh + retry once
|
|
397
|
+
final repo = _ref.read(authRepositoryProvider);
|
|
398
|
+
final restored = await repo.restoreSession();
|
|
399
|
+
if (restored is _Authenticated) {
|
|
400
|
+
final requestOptions = err.requestOptions;
|
|
401
|
+
requestOptions.headers['Authorization'] = 'Bearer ${restored.accessToken}';
|
|
402
|
+
try {
|
|
403
|
+
final cloneReq = await Dio().fetch<dynamic>(requestOptions);
|
|
404
|
+
return handler.resolve(cloneReq);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// fall through to original error
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
// Refresh failed — force logout
|
|
410
|
+
await _ref.read(authProvider.notifier).signOut();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
handler.next(err);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## ID Token Structure Reference
|
|
421
|
+
|
|
422
|
+
Keycloak places roles in the ID token as follows:
|
|
423
|
+
|
|
424
|
+
```json
|
|
425
|
+
{
|
|
426
|
+
"sub": "f1234-abcd-5678",
|
|
427
|
+
"preferred_username": "john.doe",
|
|
428
|
+
"email": "john.doe@example.com",
|
|
429
|
+
"name": "John Doe",
|
|
430
|
+
"realm_access": {
|
|
431
|
+
"roles": ["USER", "offline_access"]
|
|
432
|
+
},
|
|
433
|
+
"resource_access": {
|
|
434
|
+
"{{KEYCLOAK_CLIENT_ID}}": {
|
|
435
|
+
"roles": ["ADMIN"]
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
"scope": "openid profile email offline_access"
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
This structure is what `_extractRoles()` parses.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
# Architecture Overview — Local JWT Auth (Auth = Local)
|
|
447
|
+
|
|
448
|
+
When the backend manages authentication (no external IdP), the flow is:
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
User submits login form
|
|
452
|
+
→ AuthRepository.login(email, password)
|
|
453
|
+
→ POST /auth/login → { accessToken, refreshToken, user }
|
|
454
|
+
→ Tokens persisted to flutter_secure_storage
|
|
455
|
+
→ AuthNotifier emits AuthState.authenticated(user, accessToken)
|
|
456
|
+
→ Dio interceptor attaches Bearer header
|
|
457
|
+
→ On 401: interceptor calls /auth/refresh with refreshToken cookie/body
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## AuthRepository (Local JWT)
|
|
461
|
+
|
|
462
|
+
```dart
|
|
463
|
+
// lib/core/auth/auth_repository.dart (Local JWT variant)
|
|
464
|
+
import 'package:dio/dio.dart';
|
|
465
|
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
466
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
467
|
+
|
|
468
|
+
import '../api/dio_provider.dart';
|
|
469
|
+
import 'auth_state.dart';
|
|
470
|
+
import 'jwt_decoder.dart';
|
|
471
|
+
|
|
472
|
+
part 'auth_repository.g.dart';
|
|
473
|
+
|
|
474
|
+
class AuthRepository {
|
|
475
|
+
AuthRepository(this._dio, this._secureStorage);
|
|
476
|
+
final Dio _dio;
|
|
477
|
+
final FlutterSecureStorage _secureStorage;
|
|
478
|
+
|
|
479
|
+
static const _accessTokenKey = 'auth_access_token';
|
|
480
|
+
static const _refreshTokenKey = 'auth_refresh_token';
|
|
481
|
+
static const _userKey = 'auth_user';
|
|
482
|
+
|
|
483
|
+
Future<AuthState> login(String email, String password) async {
|
|
484
|
+
final response = await _dio.post<Map<String, dynamic>>(
|
|
485
|
+
'/auth/login',
|
|
486
|
+
data: {'email': email, 'password': password},
|
|
487
|
+
);
|
|
488
|
+
final body = response.data!;
|
|
489
|
+
final accessToken = body['accessToken'] as String;
|
|
490
|
+
final refreshToken = body['refreshToken'] as String;
|
|
491
|
+
final userJson = body['user'] as Map<String, dynamic>;
|
|
492
|
+
|
|
493
|
+
await _secureStorage.write(key: _accessTokenKey, value: accessToken);
|
|
494
|
+
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
|
|
495
|
+
await _secureStorage.write(key: _userKey, value: json.encode(userJson));
|
|
496
|
+
|
|
497
|
+
final user = AuthUser.fromJson(userJson);
|
|
498
|
+
return AuthState.authenticated(user: user, accessToken: accessToken);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
Future<AuthState?> restoreSession() async {
|
|
502
|
+
final accessToken = await _secureStorage.read(key: _accessTokenKey);
|
|
503
|
+
final userJsonString = await _secureStorage.read(key: _userKey);
|
|
504
|
+
if (accessToken == null || userJsonString == null) return null;
|
|
505
|
+
|
|
506
|
+
if (JwtDecoder.isExpired(accessToken)) {
|
|
507
|
+
return _refreshSession();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
final user = AuthUser.fromJson(json.decode(userJsonString));
|
|
511
|
+
return AuthState.authenticated(user: user, accessToken: accessToken);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
Future<AuthState?> _refreshSession() async {
|
|
515
|
+
final refreshToken = await _secureStorage.read(key: _refreshTokenKey);
|
|
516
|
+
if (refreshToken == null) return null;
|
|
517
|
+
try {
|
|
518
|
+
final response = await _dio.post<Map<String, dynamic>>(
|
|
519
|
+
'/auth/refresh',
|
|
520
|
+
data: {'refreshToken': refreshToken},
|
|
521
|
+
);
|
|
522
|
+
final body = response.data!;
|
|
523
|
+
final newAccess = body['accessToken'] as String;
|
|
524
|
+
final newRefresh = body['refreshToken'] as String?;
|
|
525
|
+
await _secureStorage.write(key: _accessTokenKey, value: newAccess);
|
|
526
|
+
if (newRefresh != null) {
|
|
527
|
+
await _secureStorage.write(key: _refreshTokenKey, value: newRefresh);
|
|
528
|
+
}
|
|
529
|
+
final userJsonString = await _secureStorage.read(key: _userKey);
|
|
530
|
+
final user = AuthUser.fromJson(json.decode(userJsonString!));
|
|
531
|
+
return AuthState.authenticated(user: user, accessToken: newAccess);
|
|
532
|
+
} catch (_) {
|
|
533
|
+
await logoutLocal();
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
Future<String?> getAccessToken() async {
|
|
539
|
+
final token = await _secureStorage.read(key: _accessTokenKey);
|
|
540
|
+
if (token == null) return null;
|
|
541
|
+
if (JwtDecoder.isExpired(token)) {
|
|
542
|
+
final refreshed = await _refreshSession();
|
|
543
|
+
if (refreshed is _Authenticated) return refreshed.accessToken;
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
return token;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
Future<void> logout() async {
|
|
550
|
+
try {
|
|
551
|
+
await _dio.post<void>('/auth/logout');
|
|
552
|
+
} catch (_) {
|
|
553
|
+
// swallow — proceed with local cleanup
|
|
554
|
+
}
|
|
555
|
+
await logoutLocal();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
Future<void> logoutLocal() async {
|
|
559
|
+
await _secureStorage.delete(key: _accessTokenKey);
|
|
560
|
+
await _secureStorage.delete(key: _refreshTokenKey);
|
|
561
|
+
await _secureStorage.delete(key: _userKey);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
@Riverpod(keepAlive: true)
|
|
566
|
+
AuthRepository authRepository(AuthRepositoryRef ref) {
|
|
567
|
+
return AuthRepository(
|
|
568
|
+
ref.watch(dioProvider),
|
|
569
|
+
const FlutterSecureStorage(),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Login Screen (Local JWT)
|
|
575
|
+
|
|
576
|
+
```dart
|
|
577
|
+
// lib/features/auth/presentation/login_screen.dart
|
|
578
|
+
import 'package:flutter/material.dart';
|
|
579
|
+
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
580
|
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
581
|
+
|
|
582
|
+
import '../../../core/auth/auth_provider.dart';
|
|
583
|
+
import '../../../core/utils/validators.dart';
|
|
584
|
+
import '../../../shared/form/form_text_field.dart';
|
|
585
|
+
|
|
586
|
+
class LoginScreen extends HookConsumerWidget {
|
|
587
|
+
const LoginScreen({super.key});
|
|
588
|
+
|
|
589
|
+
@override
|
|
590
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
591
|
+
final formKey = useMemoized(GlobalKey<FormState>.new);
|
|
592
|
+
final emailCtrl = useTextEditingController();
|
|
593
|
+
final passCtrl = useTextEditingController();
|
|
594
|
+
final loading = useState(false);
|
|
595
|
+
final errorMessage = useState<String?>(null);
|
|
596
|
+
|
|
597
|
+
Future<void> submit() async {
|
|
598
|
+
if (!formKey.currentState!.validate()) return;
|
|
599
|
+
loading.value = true;
|
|
600
|
+
errorMessage.value = null;
|
|
601
|
+
try {
|
|
602
|
+
await ref.read(authProvider.notifier).login(emailCtrl.text, passCtrl.text);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
errorMessage.value = 'Invalid email or password';
|
|
605
|
+
} finally {
|
|
606
|
+
loading.value = false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return Scaffold(
|
|
611
|
+
body: SafeArea(
|
|
612
|
+
child: Center(
|
|
613
|
+
child: Padding(
|
|
614
|
+
padding: const EdgeInsets.all(24),
|
|
615
|
+
child: Form(
|
|
616
|
+
key: formKey,
|
|
617
|
+
child: Column(
|
|
618
|
+
mainAxisSize: MainAxisSize.min,
|
|
619
|
+
children: [
|
|
620
|
+
Text(
|
|
621
|
+
'Sign in',
|
|
622
|
+
style: Theme.of(context).textTheme.displaySmall,
|
|
623
|
+
),
|
|
624
|
+
const SizedBox(height: 24),
|
|
625
|
+
FormTextField(
|
|
626
|
+
controller: emailCtrl,
|
|
627
|
+
label: 'Email',
|
|
628
|
+
keyboardType: TextInputType.emailAddress,
|
|
629
|
+
validator: Validators.compose([
|
|
630
|
+
(v) => Validators.required(v, label: 'Email'),
|
|
631
|
+
Validators.email,
|
|
632
|
+
]),
|
|
633
|
+
),
|
|
634
|
+
const SizedBox(height: 16),
|
|
635
|
+
FormTextField(
|
|
636
|
+
controller: passCtrl,
|
|
637
|
+
label: 'Password',
|
|
638
|
+
obscureText: true,
|
|
639
|
+
validator: (v) => Validators.required(v, label: 'Password'),
|
|
640
|
+
),
|
|
641
|
+
if (errorMessage.value != null) ...[
|
|
642
|
+
const SizedBox(height: 16),
|
|
643
|
+
Text(
|
|
644
|
+
errorMessage.value!,
|
|
645
|
+
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
646
|
+
),
|
|
647
|
+
],
|
|
648
|
+
const SizedBox(height: 24),
|
|
649
|
+
SizedBox(
|
|
650
|
+
width: double.infinity,
|
|
651
|
+
child: FilledButton(
|
|
652
|
+
onPressed: loading.value ? null : submit,
|
|
653
|
+
child: loading.value
|
|
654
|
+
? const SizedBox(
|
|
655
|
+
width: 18,
|
|
656
|
+
height: 18,
|
|
657
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
658
|
+
)
|
|
659
|
+
: const Text('Sign in'),
|
|
660
|
+
),
|
|
661
|
+
),
|
|
662
|
+
],
|
|
663
|
+
),
|
|
664
|
+
),
|
|
665
|
+
),
|
|
666
|
+
),
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
## Auth Callback Screen (Keycloak)
|
|
676
|
+
|
|
677
|
+
`flutter_appauth` resolves the redirect natively — no Flutter route is required. The
|
|
678
|
+
`/auth/callback` route is only used as a brief visual landing while the AppAuth library
|
|
679
|
+
finishes the exchange and the router rebuilds:
|
|
680
|
+
|
|
681
|
+
```dart
|
|
682
|
+
// lib/features/auth/presentation/auth_callback_screen.dart
|
|
683
|
+
import 'package:flutter/material.dart';
|
|
684
|
+
|
|
685
|
+
import '../../../shared/widgets/loading_indicator.dart';
|
|
686
|
+
|
|
687
|
+
class AuthCallbackScreen extends StatelessWidget {
|
|
688
|
+
const AuthCallbackScreen({super.key});
|
|
689
|
+
|
|
690
|
+
@override
|
|
691
|
+
Widget build(BuildContext context) {
|
|
692
|
+
return const Scaffold(
|
|
693
|
+
body: LoadingIndicator(message: 'Signing you in...'),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
## Secure Storage Notes
|
|
702
|
+
|
|
703
|
+
- `flutter_secure_storage` uses the Keychain on iOS (default accessibility: first unlock)
|
|
704
|
+
and EncryptedSharedPreferences on Android (API 23+)
|
|
705
|
+
- Never store raw passwords — only tokens
|
|
706
|
+
- Hive boxes containing user-sensitive data should be encrypted using
|
|
707
|
+
`HiveAesCipher` with a key generated once and stored in `flutter_secure_storage`
|
|
708
|
+
(see `storage-patterns.md`)
|
|
709
|
+
- On logout, **all** Hive boxes and secure-storage keys must be cleared
|