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 <noreply@anthropic.com>
This commit is contained in:
+10
-16
@@ -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 {
|
||||
|
||||
@@ -61,25 +61,20 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration.
|
||||
AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an
|
||||
AppCompat theme; without this it inherits the app's Theme.Material shell and crashes
|
||||
("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the
|
||||
translucent theme AppAuth itself applies to AuthorizationManagementActivity. -->
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
|
||||
already declares RedirectUriReceiverActivity with an intent-filter for
|
||||
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
|
||||
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
|
||||
be registered as a public-client (mobile/desktop) redirect in the Azure app
|
||||
registration. We only merge a theme onto that activity here: AppAuth declares no theme
|
||||
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
|
||||
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
|
||||
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
|
||||
applies to AuthorizationManagementActivity. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="org.libremail.outlook" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user