fix(02): split auth platform plans
This commit is contained in:
@@ -3,53 +3,46 @@ phase: 02-authentication-foundation
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [02-02, 02-03]
|
||||
depends_on: [02-01, 02-03]
|
||||
files_modified:
|
||||
- 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/auth/AuthHttpClient.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
|
||||
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
|
||||
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
|
||||
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
|
||||
- composeApp/src/androidMain/AndroidManifest.xml
|
||||
autonomous: true
|
||||
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated"
|
||||
- "Authenticated state contains User and householdId = null in Phase 2"
|
||||
- "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401"
|
||||
- "Refresh invalid_grant transitions silently to Unauthenticated"
|
||||
- "logout() attempts RP end-session and clears local AuthState even if end-session fails"
|
||||
- "AuthSession is a Koin singleton in authModule and wired into appModule"
|
||||
- "Android login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
|
||||
- "Android requested scopes are exactly openid profile email offline_access"
|
||||
- "Android persists full AppAuth AuthState JSON through EncryptedSharedPreferences-backed SecureAuthStateStore"
|
||||
- "Android refresh uses AppAuth fresh-token behavior and persists updated AuthState JSON"
|
||||
- "Android logout uses AppAuth end-session when metadata exposes an endpoint"
|
||||
artifacts:
|
||||
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt"
|
||||
provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28"
|
||||
contains: "householdId"
|
||||
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
|
||||
provides: "StateFlow auth owner per D-29"
|
||||
exports: ["AuthSession"]
|
||||
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt"
|
||||
provides: "Ktor client bearer auth with refreshTokens per D-17"
|
||||
contains: "refreshTokens"
|
||||
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
|
||||
provides: "GET /api/v1/me client returning MeResponse"
|
||||
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
|
||||
provides: "Android AppAuth actual per D-01, D-04, D-16, D-19, D-20"
|
||||
contains: "AuthorizationService"
|
||||
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
|
||||
provides: "Android explicit secure token storage per AUTH-02"
|
||||
contains: "EncryptedSharedPreferences"
|
||||
- path: "composeApp/src/androidMain/AndroidManifest.xml"
|
||||
provides: "recipe://callback registration for AppAuth redirect receiver"
|
||||
contains: "RedirectUriReceiverActivity"
|
||||
key_links:
|
||||
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
|
||||
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
|
||||
via: "login/refresh/logout delegate to platform AppAuth seam"
|
||||
pattern: "oidcClient"
|
||||
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
|
||||
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
|
||||
via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)"
|
||||
pattern: "meClient"
|
||||
- from: "composeApp/src/androidMain/AndroidManifest.xml"
|
||||
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
|
||||
via: "AppAuth redirect receiver for recipe://callback"
|
||||
pattern: "RedirectUriReceiverActivity|recipe"
|
||||
- from: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
|
||||
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
|
||||
via: "AuthState JSON returned by AppAuth is what AuthSession persists through the store"
|
||||
pattern: "jsonSerializeString|jsonDeserialize"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring.
|
||||
Implement the Android OIDC and secure storage actuals.
|
||||
|
||||
Purpose: compose platform OIDC/storage from Plan 03 with server `/api/v1/me` from Plan 02 into persistent app session behavior.
|
||||
Output: tested common auth state machine and DI module.
|
||||
Purpose: satisfy Android's side of AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing iOS work into the same execution plan.
|
||||
Output: Android AppAuth OidcClient actual, Android secure AuthState store, and Android callback manifest registration.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -64,98 +57,74 @@ Output: tested common auth state machine and DI module.
|
||||
@.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-02-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/di/AppModule.kt
|
||||
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
|
||||
@composeApp/src/androidMain/AndroidManifest.xml
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Write AuthSession state-machine tests</name>
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement Android AppAuth OidcClient actual</name>
|
||||
<read_first>
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
|
||||
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
|
||||
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest)
|
||||
</read_first>
|
||||
<files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt</files>
|
||||
<behavior>
|
||||
- Empty store initializes `Loading -> Unauthenticated`.
|
||||
- Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`.
|
||||
- Existing store refreshes before `/api/v1/me` and emits Authenticated without login.
|
||||
- Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error.
|
||||
- Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws.
|
||||
- Login cancelled maps to a result the UI can render as cancelled.
|
||||
</behavior>
|
||||
<action>
|
||||
Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
|
||||
- `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
|
||||
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client</name>
|
||||
<read_first>
|
||||
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
|
||||
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
|
||||
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
|
||||
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
|
||||
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29)
|
||||
- .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/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt</files>
|
||||
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt</files>
|
||||
<action>
|
||||
Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`.
|
||||
Implement Android `actual class OidcClient` using AppAuth-Android. Use `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, and `suspendCancellableCoroutine` so cancellation cancels the underlying AppAuth request.
|
||||
|
||||
Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer <token>` without logging it.
|
||||
Token exchange and refresh must serialize/deserialize the AppAuth `AuthState` JSON with `AuthState.jsonSerializeString()` and `AuthState.jsonDeserialize(...)`. Refresh must use `performActionWithFreshTokens` so updated AuthState is persisted by AuthSession. Logout must build and execute `EndSessionRequest` when the discovery metadata exposes an end-session endpoint; if unavailable, return without throwing so AuthSession can still clear local state per D-19.
|
||||
|
||||
Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers.
|
||||
|
||||
Implement `AuthSession` with `state: StateFlow<AuthState>`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear.
|
||||
Map user cancellation to `OidcResult.Cancelled`, network failures to `OidcResult.NetworkError`, and token/auth failures to `OidcResult.AuthError`. Never log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
|
||||
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q 'StateFlow<AuthState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt`
|
||||
- `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
|
||||
- `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
|
||||
- `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth`
|
||||
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
|
||||
- `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
||||
- `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
||||
- `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
||||
- `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
||||
- `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
||||
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Common auth runtime passes the state-machine tests and supports transparent refresh.</done>
|
||||
<done>Android AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Wire authModule into Koin</name>
|
||||
<name>Task 2: Implement Android secure AuthState store and callback manifest</name>
|
||||
<read_first>
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
|
||||
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
|
||||
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29)
|
||||
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
|
||||
- composeApp/src/androidMain/AndroidManifest.xml
|
||||
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-15)
|
||||
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Android secure storage correction)
|
||||
</read_first>
|
||||
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt</files>
|
||||
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml</files>
|
||||
<action>
|
||||
Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap.
|
||||
Implement Android `actual class SecureAuthStateStore` using AndroidX Security Crypto `EncryptedSharedPreferences`. Store one opaque AuthState JSON string per app install under a private key. Add a short code comment noting AndroidX Security Crypto deprecation is contained behind this abstraction because AUTH-02 explicitly calls for Android EncryptedSharedPreferences in v1.
|
||||
|
||||
Do not use no-arg `Settings()`, ordinary `SharedPreferences`, or plaintext file storage for auth tokens.
|
||||
|
||||
Register AppAuth redirect handling in `composeApp/src/androidMain/AndroidManifest.xml` with `net.openid.appauth.RedirectUriReceiverActivity` and an intent filter for scheme `recipe` and host `callback`, matching D-09 exactly (`recipe://callback`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
|
||||
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
|
||||
- `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
|
||||
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
|
||||
- `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
|
||||
- `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth`
|
||||
- `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
|
||||
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml`
|
||||
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml`
|
||||
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml`
|
||||
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>AuthSession and collaborators are available as Koin singletons for the UI gate.</done>
|
||||
<done>Android token storage is explicit and the custom URL callback is registered for AppAuth.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
@@ -165,27 +134,26 @@ Output: tested common auth state machine and DI module.
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| AuthSession -> server | Access token attached to `/api/v1/me` |
|
||||
| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches |
|
||||
| AuthSession -> UI | Auth failures influence rendered state and messages |
|
||||
| system browser -> Android app | Authorization code returns through custom URL scheme |
|
||||
| Android app -> OS secure storage | AuthState JSON containing refresh token is persisted |
|
||||
| Android app -> Authentik | Refresh and end-session requests exchange tokens with IdP |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-04-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values |
|
||||
| T-02-04-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure |
|
||||
| T-02-04-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 |
|
||||
| T-02-04-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims |
|
||||
| T-02-04-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets |
|
||||
| T-02-04-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Android manifest byte-matches `recipe://callback` |
|
||||
| T-02-04-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` and plaintext SharedPreferences in auth |
|
||||
| T-02-04-03 | Information Disclosure | AppAuth diagnostics | mitigate | Android actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers |
|
||||
| T-02-04-04 | Denial of Service | refresh path | mitigate | Use AppAuth `performActionWithFreshTokens` so expiry refresh is handled before authenticated calls |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`.
|
||||
Run `./gradlew :composeApp:compileDebugKotlinAndroid`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens.
|
||||
Android AppAuth login/refresh/logout and Android secure AuthState persistence compile independently below the file-count threshold.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
||||
Reference in New Issue
Block a user