fix(02): split auth platform plans

This commit is contained in:
2026-04-27 21:07:18 +02:00
parent f0462cbca1
commit 0b01bc8bbb
6 changed files with 640 additions and 343 deletions

View File

@@ -2,54 +2,55 @@
phase: 02-authentication-foundation
plan: 05
type: execute
wave: 4
depends_on: [02-04]
wave: 3
depends_on: [02-01, 02-03]
files_modified:
- composeApp/src/commonMain/composeResources/values/strings.xml
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
autonomous: false
requirements: [AUTH-01, AUTH-04, AUTH-05]
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
- iosApp/Podfile
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "Fresh launch in Loading shows SplashScreen with Recipe wordmark and progress indicator"
- "Unauthenticated state shows LoginScreen with Polish Authentik sign-in button"
- "Login errors render inline below the button and retry clears stale error"
- "Authenticated state shows Witaj, {displayName}! and Wyloguj się"
- "Wyloguj się returns to LoginScreen through AuthSession.logout()"
- "All Phase 2 user-facing strings come from Compose Resources"
- "iOS login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
- "iOS requested scopes are exactly openid profile email offline_access"
- "iOS persists full AppAuth AuthState JSON in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- "SwiftUI callback wiring forwards recipe://callback to the current AppAuth flow"
- "iOS logout uses AppAuth end-session when metadata exposes an endpoint"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
provides: "Auth gate rendering Splash/Login/PostLogin by AuthState"
contains: "when"
- path: "composeApp/src/commonMain/composeResources/values/strings.xml"
provides: "auth_app_name/auth_sign_in_button/auth_sign_out_button/auth_welcome_format/auth_error_*"
contains: "auth_sign_in_button"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt"
provides: "UI-SPEC login layout and inline error state"
contains: "auth_sign_in_button"
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
provides: "iOS AppAuth actual per D-01, D-04, D-16, D-19, D-20"
contains: "OIDAuthorizationService"
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt"
provides: "iOS Keychain storage per D-14"
contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- path: "iosApp/iosApp/Info.plist"
provides: "recipe URL scheme registration"
contains: "CFBundleURLSchemes"
- path: "iosApp/iosApp/iOSApp.swift"
provides: "SwiftUI openURL callback forwarding to AppAuth"
contains: "onOpenURL"
- path: "iosApp/Podfile"
provides: "AppAuth CocoaPod integration if required by chosen KMP CocoaPods setup"
contains: "AppAuth"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "collectAsState over AuthSession.state"
pattern: "collectAsState"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "onSignOutClick delegates to logout"
pattern: "logout"
- from: "iosApp/iosApp/iOSApp.swift"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "openURL forwards callback to current AppAuth external user-agent session"
pattern: "onOpenURL|currentAuthorizationFlow"
- from: "iosApp/iosApp/Info.plist"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "registered URL scheme matches redirect URI consumed by AppAuth"
pattern: "CFBundleURLSchemes|recipe"
---
<objective>
Deliver the user-facing Phase 2 auth experience and final validation gate.
Implement the iOS OIDC and secure storage actuals.
Purpose: make end-to-end auth observable: login button, loading screen, welcome confirmation, logout button, and manual iOS Authentik UAT.
Output: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution.
Purpose: satisfy iOS-primary AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing Android work into the same execution plan.
Output: iOS AppAuth OidcClient actual, iOS Keychain AuthState store, URL scheme registration, Swift callback wiring, and Podfile integration.
</objective>
<execution_context>
@@ -60,111 +61,78 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-UI-SPEC.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
@iosApp/iosApp/Info.plist
@iosApp/iosApp/iOSApp.swift
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add Compose Resources, theme seed, and ViewModel tests</name>
<task type="auto">
<name>Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- iosApp/Podfile
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
</read_first>
<files>composeApp/src/commonMain/composeResources/values/strings.xml, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt</files>
<behavior>
- String keys exist with exact Polish scaffold copy from UI-SPEC.
- `RecipeTheme` uses Material 3 light/dark schemes with primary seed `#3B6939` / dark variant `#A2D597`.
- LoginViewModel maps cancelled/network/unknown auth failures to the correct string resource keys.
- Starting a new login clears previous inline error and sets loading.
</behavior>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile</files>
<action>
Create `strings.xml` keys: `auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown` with exact UI-SPEC copy.
Implement iOS `actual class OidcClient` via AppAuth-iOS interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, token refresh/fresh-token helpers, and `OIDEndSessionRequest`. Use `suspendCancellableCoroutine` so cancellation cancels the current AppAuth request.
Add `RecipeTheme(content)` with `lightColorScheme(primary = Color(0xFF3B6939))`, `darkColorScheme(primary = Color(0xFFA2D597))`, `isSystemInDarkTheme()`, and Material 3 typography defaults. Do not add Haze, blur, images, icons, Scaffold, or marketing copy.
Request scopes exactly `openid`, `profile`, `email`, and `offline_access`. Serialize and deserialize the full `OIDAuthState` JSON blob per D-13. Refresh must use AppAuth fresh-token behavior and return updated AuthState JSON for AuthSession persistence. Logout must attempt RP-initiated end-session with `id_token_hint` when available; if end-session is unavailable or fails, surface no local-token-clearing responsibility here because AuthSession clears local state after calling logout.
Write `LoginViewModelTest` against a fake `AuthSession` result interface before implementing the ViewModel in Task 2.
Ensure AppAuth CocoaPod integration is present through the existing Gradle CocoaPods setup from Plan 02-01 and/or `iosApp/Podfile` as required by the repo's KMP CocoaPods wiring. Do not introduce an additional OIDC library.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"</automated>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `grep -q 'name="auth_sign_in_button">Zaloguj się przez Authentik' composeApp/src/commonMain/composeResources/values/strings.xml`
- `grep -q '0xFF3B6939' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
- `grep -q 'auth_error_cancelled' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` exits 0
- `grep -q 'OIDAuthorizationService' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'suspendCancellableCoroutine' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'OIDEndSessionRequest' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'AppAuth' iosApp/Podfile composeApp/build.gradle.kts`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>Resource and theme foundations match UI-SPEC and login error mapping is tested.</done>
<done>iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task>
<task type="auto">
<name>Task 2: Implement auth screens, ViewModels, and App auth gate</name>
<name>Task 2: Implement iOS Keychain store and callback wiring</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md (Component Inventory, Layout Contract, Auth Gate Routing Contract)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-14, D-15)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt</files>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift</files>
<action>
Replace template `App()` body with `RecipeTheme { val authState by authSession.state.collectAsState(); when(authState) { Loading -> SplashScreen(); Unauthenticated -> LoginScreen(koinViewModel()); Authenticated -> PostLoginPlaceholderScreen(user, koinViewModel()) } }`. State changes drive recomposition; no manual navigation or Scaffold.
Implement iOS `actual class SecureAuthStateStore` with Keychain read/write/delete for one opaque AuthState JSON string per app install. Use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` exactly per D-14; do not store AuthState in UserDefaults or plaintext files.
Implement `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` exactly from UI-SPEC: centered column, `safeContentPadding`, horizontal 16.dp, displaySmall wordmark, Login button with loading indicator, inline bodyLarge error text below button, welcome `headlineSmall`, logout `OutlinedButton`. All strings must use `stringResource(Res.string.*)`.
Add `CFBundleURLTypes` to `iosApp/iosApp/Info.plist` registering scheme `recipe`, matching redirect URI `recipe://callback`.
Implement `LoginViewModel` with method `onSignInClick()` and immutable `LoginScreenState(isLoading: Boolean, errorKey: StringResource?)`. Implement `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`. Register ViewModels in `authModule` using existing Koin Compose ViewModel pattern.
Add SwiftUI `.onOpenURL` or an app delegate bridge in `iOSApp.swift` that forwards incoming `recipe://callback` URLs to the current AppAuth external user-agent session held by the KMP iOS OidcClient bridge. Keep existing Koin initialization intact.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `! grep -R 'Click me!' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'collectAsState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'SplashScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'auth_welcome_format' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt`
- `! grep -R 'Zaloguj\\|Wyloguj\\|Witaj\\|Nie można\\|Coś poszło' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe --include='*.kt'`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
- `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- `! grep -R 'NSUserDefaults\\|UserDefaults' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
- `grep -q 'recipe' iosApp/iosApp/Info.plist`
- `grep -q 'onOpenURL\\|application(.*open' iosApp/iosApp/iOSApp.swift`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Manual iOS Authentik UAT</name>
<read_first>
- docs/authentik-setup.md
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications)
</read_first>
<files>docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-05-SUMMARY.md</files>
<action>
Run automated gate first: `./gradlew check`.
Then perform the manual UAT from `docs/authentik-setup.md` on iOS simulator/device with the real Authentik provider:
1. Fresh install opens Splash then LoginScreen.
2. Tap `Zaloguj się przez Authentik`; hosted Authentik login opens and returns through `recipe://callback`.
3. App shows `Witaj, {displayName}!`.
4. Restart after access-token expiry or shortened token lifetime; app returns to authenticated screen without credentials.
5. Tap `Wyloguj się`; app returns to LoginScreen; restart does not silently authenticate.
6. `GET /api/v1/me` returns 200 with valid token and 401 without token or with wrong-audience token.
</action>
<verify>
<automated>./gradlew check</automated>
</verify>
<acceptance_criteria>
- `./gradlew check` exits 0
- Manual UAT result recorded in `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md`
- If any UAT step fails, record exact step, observed behavior, logs with tokens redacted, and do not mark Phase 2 complete
</acceptance_criteria>
<what-built>Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI.</what-built>
<how-to-verify>Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md.</how-to-verify>
<resume-signal>Type "approved" if UAT passes, or describe the failing step and observed behavior.</resume-signal>
<done>Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior.</done>
<done>iOS token storage is explicit and the custom URL callback is wired back into AppAuth.</done>
</task>
</tasks>
@@ -174,27 +142,26 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation
| Boundary | Description |
|----------|-------------|
| UI -> AuthSession | User taps login/logout and triggers token-bearing flows |
| AuthSession -> UI | Auth errors are mapped to user-visible strings |
| Human UAT -> logs | Manual validation may inspect logs while tokens exist |
| system browser -> iOS app | Authorization code returns through custom URL scheme |
| iOS app -> Keychain | AuthState JSON containing refresh token is persisted |
| Swift shell -> KMP auth bridge | openURL callback crosses from SwiftUI into KMP/AppAuth flow state |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers |
| T-02-05-02 | Information Disclosure | logout UX | mitigate | Logout button delegates to AuthSession.logout; UAT verifies no silent restore after relaunch |
| T-02-05-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback |
| T-02-05-04 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path |
| T-02-05-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift |
| T-02-05-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Info.plist byte-matches `recipe://callback` |
| T-02-05-02 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`; grep forbids UserDefaults in auth |
| T-02-05-03 | Information Disclosure | AppAuth diagnostics | mitigate | iOS actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers |
| T-02-05-04 | Spoofing | Swift callback bridge | mitigate | `onOpenURL` forwards only registered callback URLs to the active AppAuth session |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT.
Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`.
</verification>
<success_criteria>
The app visibly satisfies Phase 2 roadmap criteria: sign in, stay signed in, sign out, and prove server `/api/v1/me` works with valid/invalid tokens.
iOS AppAuth login/refresh/logout, iOS Keychain AuthState persistence, URL scheme registration, and callback forwarding compile independently below the file-count threshold.
</success_criteria>
<output>