diff --git a/README.md b/README.md index 171d103..4106fe8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,11 @@ The app never connects directly to Supabase and includes no backend credentials. - Explicit loading, empty, unavailable and retry states. **No demo fallback**. - Gothwad Accounts sign-in (OAuth 2.0 Authorization Code + PKCE) is wired to the real Accounts service, so the wishlist, the account library and reviews - are stored in the Worker. + are stored in the Worker. The native redirect is + `kiwistore://oauth/callback` with client id `kiwi-store`; logins started by + older builds that still use `gothwadstore://oauth/callback` are accepted + during the transition, so both URIs have to be registered in Accounts until + those builds are gone. Legacy demo screen files remain for design reference but are not reachable from `MainActivity → MainStoreScreen`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf16417..d8dc5eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,9 @@ android { buildConfigField("String", "ACCOUNTS_SIGNIN_URL", "\"${httpsProperty("ACCOUNTS_SIGNIN_URL", "https://accounts.gothwadtech.com/")}\"") buildConfigField("String", "SHIZUKU_DOWNLOAD_URL", "\"${httpsProperty("SHIZUKU_DOWNLOAD_URL", "https://shizuku.rikka.app/")}\"") buildConfigField("String", "OAUTH_CLIENT_ID", "\"${providers.gradleProperty("OAUTH_CLIENT_ID").orElse("kiwi-store").get()}\"") - buildConfigField("String", "OAUTH_REDIRECT_URI", "\"gothwadstore://oauth/callback\"") + // Native redirect registered with Gothwad Accounts. Must match the + // `kiwistore` VIEW intent filter in AndroidManifest.xml. + buildConfigField("String", "OAUTH_REDIRECT_URI", "\"kiwistore://oauth/callback\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14f7ff7..480b259 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,7 +65,20 @@ - + + + + + + + + + diff --git a/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt b/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt index 860b75c..c8c97ef 100644 --- a/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt +++ b/app/src/main/java/com/gothwad/store/data/auth/AuthManager.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject @@ -42,12 +43,21 @@ sealed interface AuthState { * * OAuth 2.0 Authorization Code + PKCE, exactly like the web storefront: the * browser (Custom Tab) hits Accounts, Accounts redirects back to - * `gothwadstore://oauth/callback`, and the app exchanges the code for tokens. + * `kiwistore://oauth/callback`, and the app exchanges the code for tokens. * No client secret ever ships in the APK, and no Supabase key is involved. + * + * Transition: builds released before the scheme rename registered + * `gothwadstore://oauth/callback`. [handleRedirect] still accepts that scheme + * so a sign-in started by an old APK can finish after updating; the matching + * legacy intent filter lives in `AndroidManifest.xml`. Both go away together. */ -class AuthManager(private val context: Context) { +class AuthManager( + private val context: Context, + private val accountsBaseUrl: String = BuildConfig.ACCOUNTS_API_BASE_URL, + httpClient: OkHttpClient = StoreNetwork.downloadClient, +) { - private val prefs = context.getSharedPreferences("gothwad_accounts", Context.MODE_PRIVATE) + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val refreshMutex = Mutex() private val _state = MutableStateFlow(AuthState.Loading) @@ -56,7 +66,7 @@ class AuthManager(private val context: Context) { private val _lastError = MutableStateFlow(null) val lastError: StateFlow = _lastError.asStateFlow() - private val client = StoreNetwork.downloadClient.newBuilder() + private val client = httpClient.newBuilder() .callTimeout(45, TimeUnit.SECONDS) .build() @@ -67,25 +77,13 @@ class AuthManager(private val context: Context) { } // ── Sign-in ──────────────────────────────────────────────────────────── - fun startSignIn(scope: String = "profile email offline_access"): Result = runCatching { + fun startSignIn(scope: String = DEFAULT_SCOPE): Result = runCatching { val verifier = randomUrlSafe(32) val challenge = base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())) val state = randomUrlSafe(16) - prefs.edit() - .putString(KEY_VERIFIER, verifier) - .putString(KEY_STATE, state) - .putLong(KEY_STARTED, System.currentTimeMillis()) - .apply() + rememberPendingSignIn(verifier, state) - val url = Uri.parse("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/authorize").buildUpon() - .appendQueryParameter("client_id", BuildConfig.OAUTH_CLIENT_ID) - .appendQueryParameter("redirect_uri", BuildConfig.OAUTH_REDIRECT_URI) - .appendQueryParameter("response_type", "code") - .appendQueryParameter("scope", scope) - .appendQueryParameter("state", state) - .appendQueryParameter("code_challenge", challenge) - .appendQueryParameter("code_challenge_method", "S256") - .build() + val url = authorizeUrl(scope, state, challenge) val customTabs = CustomTabsIntent.Builder() .setShowTitle(true) @@ -99,9 +97,39 @@ class AuthManager(private val context: Context) { } } - /** @return true when the deep link belonged to the OAuth callback. */ + /** + * Stores a pending PKCE session. [startSignIn] uses it, and unit tests use + * it to exercise [exchangeCode] without opening a Custom Tab. + */ + internal fun rememberPendingSignIn(verifier: String, state: String) { + prefs.edit() + .putString(KEY_VERIFIER, verifier) + .putString(KEY_STATE, state) + .putLong(KEY_STARTED, System.currentTimeMillis()) + .apply() + } + + /** Accounts consent URL; `redirect_uri` must match [BuildConfig.OAUTH_REDIRECT_URI]. */ + internal fun authorizeUrl(scope: String, state: String, codeChallenge: String): Uri = + Uri.parse("${accountsBaseUrl}oauth/authorize").buildUpon() + .appendQueryParameter("client_id", BuildConfig.OAUTH_CLIENT_ID) + .appendQueryParameter("redirect_uri", BuildConfig.OAUTH_REDIRECT_URI) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("scope", scope) + .appendQueryParameter("state", state) + .appendQueryParameter("code_challenge", codeChallenge) + .appendQueryParameter("code_challenge_method", "S256") + .build() + + /** + * @return true when the deep link belonged to the OAuth callback. + * + * Only the current `kiwistore` scheme (plus the legacy `gothwadstore` + * scheme for the transition window) is accepted; anything else is ignored. + */ fun handleRedirect(uri: Uri): Boolean { - if (uri.scheme != "gothwadstore" || uri.host != "oauth") return false + if (!isOAuthCallback(uri)) return false + val error = uri.getQueryParameter("error") if (error != null) { _lastError.value = "Gothwad sign-in was cancelled ($error)." @@ -129,6 +157,16 @@ class AuthManager(private val context: Context) { return true } + /** + * True only for `kiwistore://oauth/callback` and the transition-only + * `gothwadstore://oauth/callback`. The host and path are checked too, so a + * look-alike scheme or a different deep link never reaches the token code. + */ + internal fun isOAuthCallback(uri: Uri): Boolean = + uri.host == "oauth" && + uri.path.orEmpty().startsWith("/callback") && + uri.scheme in OAUTH_SCHEMES + suspend fun exchangeCode(code: String) { val verifier = prefs.getString(KEY_VERIFIER, null) ?: error("Sign-in session expired. Please tap Sign in again.") @@ -139,7 +177,7 @@ class AuthManager(private val context: Context) { .put("client_id", BuildConfig.OAUTH_CLIENT_ID) .put("code_verifier", verifier) .toString() - val json = postJson("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/token", body, null) + val json = postJson("${accountsBaseUrl}oauth/token", body, null) val access = json.optString("access_token").takeIf { it.isNotBlank() } ?: error("Gothwad Accounts returned no access token.") val refresh = json.optString("refresh_token").takeIf { it.isNotBlank() } @@ -218,7 +256,7 @@ class AuthManager(private val context: Context) { .put("refresh_token", refreshToken) .put("client_id", BuildConfig.OAUTH_CLIENT_ID) .toString() - val json = postJson("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/token", body, null) + val json = postJson("${accountsBaseUrl}oauth/token", body, null) val access = json.optString("access_token").takeIf { it.isNotBlank() } ?: error("Token refresh failed.") val newRefresh = json.optString("refresh_token").takeIf { it.isNotBlank() } ?: refreshToken @@ -233,7 +271,7 @@ class AuthManager(private val context: Context) { private suspend fun fetchUserInfo(): GothwadUser? { val token = accessTokenBlocking() ?: return null val request = Request.Builder() - .url("${BuildConfig.ACCOUNTS_API_BASE_URL}oauth/userinfo") + .url("${accountsBaseUrl}oauth/userinfo") .header("Authorization", "Bearer $token") .header("Accept", "application/json") .build() @@ -281,16 +319,30 @@ class AuthManager(private val context: Context) { private fun base64Url(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - private companion object { - const val KEY_ACCESS = "access_token" - const val KEY_REFRESH = "refresh_token" - const val KEY_EXPIRES_AT = "expires_at" - const val KEY_VERIFIER = "pkce_verifier" - const val KEY_STATE = "oauth_state" - const val KEY_STARTED = "started_at" + internal companion object { + /** Current OAuth redirect scheme; must match `BuildConfig.OAUTH_REDIRECT_URI`. */ + const val OAUTH_SCHEME = "kiwistore" + + /** + * Transition only: builds released before the rename sent Accounts back + * with this scheme. It is accepted so their in-flight sign-ins can + * finish after the update. Remove it together with the legacy intent + * filter in `AndroidManifest.xml` once no such build is in circulation. + */ + const val LEGACY_OAUTH_SCHEME = "gothwadstore" + + private val OAUTH_SCHEMES = setOf(OAUTH_SCHEME, LEGACY_OAUTH_SCHEME) + + internal const val PREFS_NAME = "gothwad_accounts" + internal const val KEY_ACCESS = "access_token" + internal const val KEY_REFRESH = "refresh_token" + internal const val KEY_EXPIRES_AT = "expires_at" + internal const val KEY_VERIFIER = "pkce_verifier" + internal const val KEY_STATE = "oauth_state" + internal const val KEY_STARTED = "started_at" /** The real scope set is stored when signing in; kept for readable signing. */ - const val DEFAULT_SCOPE = "profile email offline_access" + internal const val DEFAULT_SCOPE = "profile email offline_access" } } diff --git a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt index a8f736c..0474bb0 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/AccountScreens.kt @@ -506,7 +506,7 @@ fun SignInScreen(vm: StoreViewModel, nav: NavController) { ) com.gothwad.store.ui.components.IconTextRow( Icons.Filled.AccountCircle, - "After you approve, Android returns to gothwadstore://oauth/callback and the app " + + "After you approve, Android returns to kiwistore://oauth/callback and the app " + "exchanges the one-time code for tokens.", ) com.gothwad.store.ui.components.IconTextRow( diff --git a/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt index 4d5a934..aaa8826 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/InstallScreens.kt @@ -597,8 +597,8 @@ fun AdbScreen(vm: StoreViewModel, nav: NavController) { item { SectionHeader(title = "Commands") } item { Column(Modifier.padding(horizontal = Spacing.screenH)) { - CommandBlock("adb install -r gothwadstore.apk") - CommandBlock("adb shell pm install -r /sdcard/Download/gothwadstore.apk") + CommandBlock("adb install -r KiwiStore.apk") + CommandBlock("adb shell pm install -r /sdcard/Download/KiwiStore.apk") CommandBlock("adb shell pm list packages | grep gothwad") CommandBlock("adb uninstall $packageName") } diff --git a/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt b/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt new file mode 100644 index 0000000..b8cc3c4 --- /dev/null +++ b/app/src/test/java/com/gothwad/store/AuthRedirectTest.kt @@ -0,0 +1,141 @@ +package com.gothwad.store + +import android.app.Application +import android.content.Intent +import android.net.Uri +import com.gothwad.store.data.auth.AuthManager +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The OAuth redirect moved from `gothwadstore://` to `kiwistore://`. These + * tests pin the new contract: the authorize request, the callback filter in the + * manifest and the token exchange must all use `kiwistore://oauth/callback`, + * while the old scheme is only tolerated for logins started by an old APK. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AuthRedirectTest { + + private lateinit var server: MockWebServer + private lateinit var auth: AuthManager + + @Before + fun setUp() { + server = MockWebServer() + server.start() + auth = AuthManager( + context = RuntimeEnvironment.getApplication(), + accountsBaseUrl = server.url("/api/").toString(), + httpClient = OkHttpClient(), + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `build config points at kiwistore`() { + assertEquals("kiwistore://oauth/callback", BuildConfig.OAUTH_REDIRECT_URI) + assertEquals("kiwistore", Uri.parse(BuildConfig.OAUTH_REDIRECT_URI).scheme) + assertEquals(AuthManager.OAUTH_SCHEME, Uri.parse(BuildConfig.OAUTH_REDIRECT_URI).scheme) + assertEquals("kiwi-store", BuildConfig.OAUTH_CLIENT_ID) + } + + @Test + fun `authorize request asks accounts to return to kiwistore`() { + val uri = auth.authorizeUrl( + scope = "profile email offline_access", + state = "state-123", + codeChallenge = "challenge-abc", + ) + + assertTrue(uri.path.orEmpty().endsWith("oauth/authorize")) + assertEquals("kiwistore://oauth/callback", uri.getQueryParameter("redirect_uri")) + assertEquals("kiwi-store", uri.getQueryParameter("client_id")) + assertEquals("code", uri.getQueryParameter("response_type")) + assertEquals("state-123", uri.getQueryParameter("state")) + assertEquals("challenge-abc", uri.getQueryParameter("code_challenge")) + assertEquals("S256", uri.getQueryParameter("code_challenge_method")) + } + + @Test + fun `kiwistore callback is accepted`() { + assertTrue(auth.handleRedirect(Uri.parse("kiwistore://oauth/callback?error=access_denied"))) + assertEquals( + "Gothwad sign-in was cancelled (access_denied).", + auth.lastError.value, + ) + } + + @Test + fun `legacy gothwadstore callback still works during the transition`() { + assertTrue(auth.handleRedirect(Uri.parse("gothwadstore://oauth/callback?error=access_denied"))) + assertEquals( + "Gothwad sign-in was cancelled (access_denied).", + auth.lastError.value, + ) + } + + @Test + fun `foreign schemes, hosts and paths are ignored`() { + assertFalse(auth.handleRedirect(Uri.parse("https://kiwistore.gothwadtech.com/oauth/callback?code=x"))) + assertFalse(auth.handleRedirect(Uri.parse("kiwistoreevil://oauth/callback?code=x"))) + assertFalse(auth.handleRedirect(Uri.parse("kiwistore://other/callback?code=x"))) + assertFalse(auth.handleRedirect(Uri.parse("kiwistore://oauth/reset?code=x"))) + assertFalse(auth.handleRedirect(Uri.parse("gothwadstore://other/callback?code=x"))) + } + + @Test + fun `token exchange sends the kiwistore redirect uri`() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"access_token":"access-1","refresh_token":"refresh-1","expires_in":3600}"""), + ) + auth.rememberPendingSignIn(verifier = "verifier-abc", state = "state-123") + + runBlocking { auth.exchangeCode("code-xyz") } + + val recorded = server.takeRequest() + assertEquals("/api/oauth/token", recorded.path) + val body = JSONObject(recorded.body.readUtf8()) + assertEquals("authorization_code", body.getString("grant_type")) + assertEquals("code-xyz", body.getString("code")) + assertEquals("kiwistore://oauth/callback", body.getString("redirect_uri")) + assertEquals("kiwi-store", body.getString("client_id")) + assertEquals("verifier-abc", body.getString("code_verifier")) + } + + @Test + fun `manifest routes both schemes to the store activity`() { + val context = RuntimeEnvironment.getApplication() + val activity = MainActivity::class.java.name + + fun handlers(url: String) = context.packageManager.queryIntentActivities( + Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE), + 0, + ).orEmpty().map { it.activityInfo.name } + + assertTrue(handlers("kiwistore://oauth/callback").contains(activity)) + assertTrue(handlers("kiwistore://oauth/callback?code=abc").contains(activity)) + // Transition only; removed together with AuthManager.LEGACY_OAUTH_SCHEME. + assertTrue(handlers("gothwadstore://oauth/callback").contains(activity)) + assertFalse(handlers("kiwistore://oauth/other").contains(activity)) + } +} diff --git a/docs/ANDROID_INTEGRATION.md b/docs/ANDROID_INTEGRATION.md index fbe82b8..4746f85 100644 --- a/docs/ANDROID_INTEGRATION.md +++ b/docs/ANDROID_INTEGRATION.md @@ -69,6 +69,29 @@ validation, implement secure token storage excluded from backup, session validation/expiry and logout. Then wire `/me`, wishlist/library and review/report mutations. No tokens, fake user or sign-in success are persisted by this phase. +## Account redirect (native OAuth) + +| Setting | Value | +| --- | --- | +| Client id | `kiwi-store` (`OAUTH_CLIENT_ID`, override with `-POAUTH_CLIENT_ID=...`) | +| Redirect URI | `kiwistore://oauth/callback` (`OAUTH_REDIRECT_URI` in `app/build.gradle.kts`) | +| Authorize endpoint | `oauth/authorize` (Authorization Code + PKCE S256) | +| Token endpoint | `oauth/token` with the same `redirect_uri` | + +The manifest declares a VIEW/BROWSABLE intent filter for the `kiwistore` +scheme. It also keeps a `gothwadstore` filter as a transition aid: releases +before the rename handed the callback to `gothwadstore://oauth/callback`, and +an app update can land while such a sign-in is in flight. `AuthManager` +accepts that legacy scheme only for this window — the request built in +`authorizeUrl` and the code exchange in `exchangeCode` always use the +`kiwistore` value from `BuildConfig`. Remove both the legacy filter and +`AuthManager.LEGACY_OAUTH_SCHEME` together once no old build is in use, and +unregister the old URI in Accounts at the same time. + +Accounts must have both redirect URIs registered while the transition lasts; +an unregistered `redirect_uri` makes the token exchange fail even though the +browser handed the code back to the app. + ## Tests and verification status Added MockWebServer tests for query encoding, published-only results,