216 lines
15 KiB
Markdown
216 lines
15 KiB
Markdown
---
|
|
phase: 02-authentication-foundation
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [02-01]
|
|
files_modified:
|
|
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
|
|
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
|
|
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.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
|
|
- 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
|
|
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt
|
|
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt
|
|
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
|
|
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt
|
|
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
|
|
autonomous: true
|
|
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
|
|
must_haves:
|
|
truths:
|
|
- "iOS and Android login use AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
|
|
- "Requested scopes are exactly openid profile email offline_access"
|
|
- "AuthState JSON is stored through explicit iOS Keychain and Android EncryptedSharedPreferences-backed stores"
|
|
- "Every configured KMP target has a SecureAuthStateStore actual, including JVM and Wasm stubs"
|
|
- "JVM target has DEV_AUTH_TOKEN dev stub; Wasm target throws NotImplementedError(\"Wasm OIDC: v2\")"
|
|
- "Logout platform clients support RP-initiated end-session and local store clearing"
|
|
artifacts:
|
|
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
|
|
provides: "expect OIDC seam with suspend login/refresh/logout per D-01..D-04"
|
|
contains: "expect class OidcClient"
|
|
- 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/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt"
|
|
provides: "iOS Keychain storage with AfterFirstUnlockThisDeviceOnly per D-14"
|
|
contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
|
|
- path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt"
|
|
provides: "JVM dev-only in-memory AuthState store actual so desktop tests compile"
|
|
contains: "actual class SecureAuthStateStore"
|
|
- path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt"
|
|
provides: "Wasm non-persistent AuthState store actual so wasm target compiles while OIDC remains v2"
|
|
contains: "actual class SecureAuthStateStore"
|
|
- path: "iosApp/iosApp/Info.plist"
|
|
provides: "recipe URL scheme registration"
|
|
contains: "CFBundleURLSchemes"
|
|
key_links:
|
|
- 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: "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"
|
|
---
|
|
|
|
<objective>
|
|
Implement the platform OIDC and secure storage boundary for mobile auth.
|
|
|
|
Purpose: satisfy AUTH-01/AUTH-02 platform requirements before `AuthSession` composes them into app state.
|
|
Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
|
|
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/REQUIREMENTS.md
|
|
@.planning/phases/02-authentication-foundation/02-CONTEXT.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-01-SUMMARY.md
|
|
@AGENTS.md
|
|
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
|
|
@iosApp/iosApp/iOSApp.swift
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Define OidcClient and secure store common contracts</name>
|
|
<read_first>
|
|
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
|
|
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20)
|
|
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1)
|
|
</read_first>
|
|
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt</files>
|
|
<action>
|
|
Create `OidcResult` sealed type with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError`.
|
|
|
|
Create `expect class OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The contract must state that native actuals use AppAuth and request scopes exactly `openid profile email offline_access`.
|
|
|
|
Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. Add contract tests using a fake in-memory implementation to lock read/write/clear semantics; platform implementations compile in Task 2.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:jvmTest</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
|
|
- `grep -q 'openid profile email offline_access' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
|
|
- `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
|
|
- `./gradlew :composeApp:jvmTest` exits 0
|
|
</acceptance_criteria>
|
|
<done>Common auth platform seams exist with testable store semantics and exact scope contract.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage</name>
|
|
<read_first>
|
|
- composeApp/build.gradle.kts
|
|
- composeApp/src/androidMain/AndroidManifest.xml
|
|
- iosApp/iosApp/Info.plist
|
|
- iosApp/iosApp/iOSApp.swift
|
|
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-09, D-13, D-14, D-19, D-20)
|
|
</read_first>
|
|
<files>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, 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</files>
|
|
<action>
|
|
Android: implement AppAuth-Android using `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, `suspendCancellableCoroutine`, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, `performActionWithFreshTokens`, and `EndSessionRequest` when metadata exposes end-session. Register `net.openid.appauth.RedirectUriReceiverActivity` for scheme `recipe` host `callback`.
|
|
|
|
Android secure storage decision: use AndroidX Security Crypto `EncryptedSharedPreferences` behind `SecureAuthStateStore.android.kt` for AUTH-02 because the requirement explicitly calls out Android EncryptedSharedPreferences. Document in code comment that the dependency is deprecated upstream but isolated behind `SecureAuthStateStore`; do not use no-arg `Settings()` or ordinary `SharedPreferences` for auth tokens.
|
|
|
|
iOS: implement AppAuth-iOS via CocoaPods/interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, `OIDTokenRequest` refresh/fresh-token helpers, `OIDEndSessionRequest`, and `suspendCancellableCoroutine`. Register `CFBundleURLTypes` for `recipe`. Add SwiftUI `.onOpenURL` or app delegate bridge in `iOSApp.swift` to resume the current AppAuth flow.
|
|
|
|
iOS secure storage: implement Keychain read/write/delete with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Persist the full AppAuth AuthState JSON blob per D-13.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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 -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
|
|
- `grep -q 'offline_access' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
|
|
- `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
|
|
- `grep -q 'recipe' composeApp/src/androidMain/AndroidManifest.xml`
|
|
- `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
|
|
- `./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
|
|
</acceptance_criteria>
|
|
<done>Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Add JVM and Wasm target actuals</name>
|
|
<read_first>
|
|
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03)
|
|
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
|
|
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
|
|
</read_first>
|
|
<files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt</files>
|
|
<action>
|
|
JVM actual reads `DEV_AUTH_TOKEN` from environment and returns `OidcResult.Success(authStateJson = "dev:$token", accessToken = token, idToken = null, expiresAtEpochMillis = Long.MAX_VALUE)` when present. If missing, return `OidcResult.AuthError("DEV_AUTH_TOKEN is not set")`; do not hardcode a usable bearer token.
|
|
|
|
JVM `SecureAuthStateStore` actual must compile for desktop dev/tests without pretending to be production secure storage. Implement `actual class SecureAuthStateStore` with a private nullable in-memory `authStateJson` property and exact methods `read()`, `write(authStateJson: String)`, and `clear()`. This store is dev-only and process-local; do not use it for mobile targets.
|
|
|
|
Wasm actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login/refresh/logout per D-03.
|
|
|
|
Wasm `SecureAuthStateStore` actual must also exist so `:composeApp:compileKotlinWasmJs` compiles. Implement `actual class SecureAuthStateStore` with the same private nullable in-memory `authStateJson` property and `read()`, `write(authStateJson: String)`, `clear()` methods. Because Wasm OIDC itself throws `NotImplementedError("Wasm OIDC: v2")`, this store is non-persistent and only satisfies the KMP actual contract.
|
|
</action>
|
|
<verify>
|
|
<automated>./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`
|
|
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt`
|
|
- `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt`
|
|
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt`
|
|
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
|
|
</acceptance_criteria>
|
|
<done>Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| system browser -> app | Authorization code returns through custom URL scheme |
|
|
| app process -> OS secure storage | AuthState JSON containing refresh token is persisted |
|
|
| app -> Authentik | Refresh and end-session requests exchange tokens with IdP |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-02-03-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; redirect URI byte-matched to `recipe://callback` |
|
|
| T-02-03-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` in auth |
|
|
| T-02-03-03 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` |
|
|
| T-02-03-04 | Information Disclosure | AppAuth diagnostics | mitigate | Do not log AuthState JSON, access tokens, refresh tokens, id tokens, or Authorization headers |
|
|
| T-02-03-05 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and is not a release surface |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
AUTH-01/AUTH-02 platform primitives exist: native AppAuth login/refresh/logout compiles, secure stores are explicit, and secondary target stubs match decisions.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`.
|
|
</output>
|