From 657af48052a812dce198caa74ea65c60c935ab52 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 23:44:17 -0500 Subject: [PATCH] refactor(auth): remove dead Gmail OAuth code path Gmail authenticates via app password + preconfigured IMAP/SMTP (decision in #9), never OAuth, so the Gmail-OAuth implementation was unreachable dead code. Remove it while keeping Outlook's AppAuth OAuth path intact. - Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept). - Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the gmailAuthManager injection, and the SCOPE_GMAIL constant in MailConnectionFactory. - Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the gmailRedirectScheme val from app/build.gradle.kts. - Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest now registers that scheme on RedirectUriReceiverActivity, so the app manifest's now-redundant Outlook intent-filter is removed; the AppCompat theme override (crash fix) is preserved. Verified the merged manifest. - Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example. authType persists as the enum name in a TEXT column, so removing a constant needs no Room schema change or migration. Closes #39 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 26 ++-- app/src/main/AndroidManifest.xml | 27 ++--- .../org/libremail/auth/GmailAuthManager.kt | 111 ------------------ .../data/sync/MailConnectionFactory.kt | 5 - .../org/libremail/domain/model/Account.kt | 3 - secrets.properties.example | 9 +- 6 files changed, 23 insertions(+), 158 deletions(-) delete mode 100644 app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f0f640..ca415ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,21 +14,12 @@ plugins { alias(libs.plugins.detekt) } -// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent. +// Read optional build secrets (Outlook client id, release signing) from secrets.properties +// (git-ignored). Absent values fall back to the defaults below. val secretsFile = rootProject.file("secrets.properties") val secrets = Properties().apply { if (secretsFile.exists()) secretsFile.inputStream().use { load(it) } } -val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "") - -// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client -// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid -// until a real client id is set in secrets.properties. -val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) { - "com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com") -} else { - "org.libremail.oauth" -} // Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via // secrets.properties; defaults to the app's registered client id. @@ -37,6 +28,10 @@ val outlookOAuthClientId: String = secrets.getProperty( "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", ) +// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of +// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration. +val outlookRedirectScheme = "org.libremail.outlook" + // Optional release signing, configured via git-ignored secrets.properties. When absent, release // builds fall back to the debug key (installable for testing, but not publishable). val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE") @@ -55,12 +50,11 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"") - buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"") buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"") - buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"") - // AppAuth captures the OAuth redirect via this custom scheme. - manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme + buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"") + // AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on + // RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app. + manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ee562c..434581c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,25 +61,20 @@ android:resource="@xml/file_paths" /> - + - - - - - - - + tools:node="merge" /> diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt deleted file mode 100644 index 080dd36..0000000 --- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.auth - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.util.Base64 -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.suspendCancellableCoroutine -import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationException -import net.openid.appauth.AuthorizationRequest -import net.openid.appauth.AuthorizationResponse -import net.openid.appauth.AuthorizationService -import net.openid.appauth.AuthorizationServiceConfiguration -import net.openid.appauth.ResponseTypeValues -import org.json.JSONObject -import org.libremail.BuildConfig -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted - * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2. - */ -@Singleton -class GmailAuthManager @Inject constructor(@ApplicationContext private val context: Context) { - private val serviceConfig = AuthorizationServiceConfiguration( - Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"), - Uri.parse("https://oauth2.googleapis.com/token"), - ) - - /** False until a Google OAuth client id is provided in secrets.properties (see README). */ - val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank() - - fun createAuthIntent(): Intent { - val request = AuthorizationRequest.Builder( - serviceConfig, - BuildConfig.GMAIL_OAUTH_CLIENT_ID, - ResponseTypeValues.CODE, - Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI), - ) - .setScope("openid email profile https://mail.google.com/") - .build() - return AuthorizationService(context).getAuthorizationRequestIntent(request) - } - - suspend fun exchangeToken(responseIntent: Intent): OAuthResult { - val response = AuthorizationResponse.fromIntent(responseIntent) - val exception = AuthorizationException.fromIntent(responseIntent) - if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") - - val service = AuthorizationService(context) - try { - val tokenResponse = suspendCancellableCoroutine { continuation -> - service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> - if (token != null) { - continuation.resume(token) - } else { - continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) - } - } - } - val authState = AuthState(response, exception).apply { update(tokenResponse, null) } - val email = emailFromIdToken(tokenResponse.idToken) - ?: throw IllegalStateException("Could not read the account email from the token") - return OAuthResult( - email = email, - accessToken = tokenResponse.accessToken.orEmpty(), - authStateJson = authState.jsonSerializeString(), - ) - } finally { - service.dispose() - } - } - - /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ - suspend fun freshAccessToken(authStateJson: String): FreshToken { - val authState = AuthState.jsonDeserialize(authStateJson) - val service = AuthorizationService(context) - try { - val accessToken = suspendCancellableCoroutine { continuation -> - authState.performActionWithFreshTokens(service) { token, _, error -> - if (token != null) { - continuation.resume(token) - } else { - continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) - } - } - } - return FreshToken( - accessToken = accessToken, - authStateJson = authState.jsonSerializeString(), - accessTokenExpiry = authState.accessTokenExpirationTime, - ) - } finally { - service.dispose() - } - } - - private fun emailFromIdToken(idToken: String?): String? { - if (idToken.isNullOrBlank()) return null - return runCatching { - val payload = idToken.split(".").getOrNull(1) ?: return null - val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) - JSONObject(json).optString("email").ifBlank { null } - }.getOrNull() - } -} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index beacac7..26bdf62 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.libremail.auth.FreshToken -import org.libremail.auth.GmailAuthManager import org.libremail.auth.OutlookAuthManager import org.libremail.data.local.toImapParams import org.libremail.data.local.toSmtpParams @@ -23,7 +22,6 @@ import javax.inject.Singleton @Singleton class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, - private val gmailAuthManager: GmailAuthManager, private val outlookAuthManager: OutlookAuthManager, private val settingsRepository: SettingsRepository, ) { @@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor( private suspend fun resolveSecret(account: Account): String = when (account.authType) { AuthType.PASSWORD_IMAP -> credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") - AuthType.OAUTH_GMAIL -> - cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken) AuthType.OAUTH_OUTLOOK -> cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken) } @@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor( private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls private companion object { - const val SCOPE_GMAIL = "gmail" const val SCOPE_OUTLOOK = "outlook" const val SCOPE_GRAPH = "graph" const val EXPIRY_BUFFER_MS = 60_000L diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index 3110427..3ccb80e 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -3,9 +3,6 @@ package org.libremail.domain.model /** How an account authenticates with its mail server. */ enum class AuthType { - /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ - OAUTH_GMAIL, - /** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ OAUTH_OUTLOOK, diff --git a/secrets.properties.example b/secrets.properties.example index 48df0a9..d7f3dd1 100644 --- a/secrets.properties.example +++ b/secrets.properties.example @@ -1,11 +1,6 @@ -# Copy this file to `secrets.properties` (which is git-ignored) and fill in the value. +# Copy this file to `secrets.properties` (which is git-ignored) and fill in the values you need. +# Every value below is optional — the build works with the defaults when this file is absent. # -# Gmail OAuth 2.0 *Android* client ID created in Google Cloud Console. -# See the README ("Gmail account setup") for the exact steps. Used by the app for -# the Authorization Code + PKCE login flow; no client secret is required for an -# installed Android app. -GMAIL_OAUTH_CLIENT_ID= - # Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build; # set this only to use your own Azure app registration. #OUTLOOK_OAUTH_CLIENT_ID=