From 1f63773faac6a8729a8b24fd74d84d94e12b509c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 18:20:41 -0500 Subject: [PATCH] Add Outlook / Microsoft account support (OAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook signs in through the Microsoft identity platform via AppAuth (Authorization Code + PKCE, no secret). One consent requests the outlook.office.com IMAP and SMTP scopes; because they share a single resource, the resulting access token authenticates both IMAP receive and SMTP send over XOAUTH2 — reusing the existing ImapClient and SmtpSender, with no Graph call or second token. The "common" tenant covers personal and work/school accounts. - OutlookAuthManager (mirrors GmailAuthManager) + AuthType.OAUTH_OUTLOOK + Account.outlook() with the unified outlook.office365.com / smtp.office365.com endpoints. - MailConnectionFactory refreshes either OAuth provider's token; XOAUTH2 now applies to any non-password account. AccountRepository.addOutlookAccount verifies via IMAP, then persists. - "Sign in with Microsoft" on the account-setup screen; the manifest registers the org.libremail.outlook:// redirect. The client id ships in the build, overridable via secrets.properties (OUTLOOK_OAUTH_CLIENT_ID); README documents the Azure app registration. - Verified: assemble/lint/test green; on the emulator the button launches AppAuth and Microsoft renders its live sign-in page (client id, redirect, and scopes all accepted). Co-Authored-By: Claude Opus 4.8 --- README.md | 22 +++- app/build.gradle.kts | 9 ++ app/src/main/AndroidManifest.xml | 16 +++ .../org/libremail/auth/OutlookAuthManager.kt | 119 ++++++++++++++++++ .../data/repository/AccountRepositoryImpl.kt | 13 ++ .../data/sync/MailConnectionFactory.kt | 27 ++-- .../org/libremail/domain/model/Account.kt | 13 ++ .../domain/repository/AccountRepository.kt | 3 + .../ui/accountsetup/AccountSetupScreen.kt | 12 ++ .../ui/accountsetup/AccountSetupViewModel.kt | 23 ++++ app/src/main/res/values/strings.xml | 1 + 11 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt diff --git a/README.md b/README.md index 66acc5e..fef90b7 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ experience with power-user features tucked under an **Advanced Settings** group. > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer, and attach files when composing; **multiple accounts** — a unified inbox > with per-account filtering; and -> **search** across cached mail and the server (IMAP SEARCH). Outlook Graph send is upcoming. +> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft** +> accounts (OAuth 2.0 sign-in, IMAP receive + SMTP send over XOAUTH2). ## Features (target MVP) -- Send and receive email with **Gmail** (OAuth 2.0) and **any IMAP/SMTP** provider. +- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider. - Material You dynamic theming, light/dark, edge-to-edge. - Clean compose screen with phone/account contacts integration. - Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail. @@ -88,6 +89,23 @@ assessment for the restricted scope. 5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set `GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`. +## Outlook / Microsoft account setup (OAuth client) + +Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret), +requesting the `outlook.office.com` **IMAP** and **SMTP** scopes — a single token +authenticates both IMAP receive and SMTP send over XOAUTH2. A working client ID ships with +the build; to use your own Azure app registration instead: + +1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.** + Supported account types: *Accounts in any organizational directory and personal Microsoft + accounts*. +2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect + URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**. +3. **API permissions:** add the delegated **Office 365 Exchange Online** scopes + `IMAP.AccessAsUser.All` and `SMTP.Send` (`openid`/`email`/`offline_access` come from OIDC). +4. Copy the **Application (client) ID** into `secrets.properties` as + `OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default). + ## Architecture Offline-first, unidirectional, layered: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e862077..ece7c90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,6 +27,13 @@ val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleu "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. +val outlookOAuthClientId: String = secrets.getProperty( + "OUTLOOK_OAUTH_CLIENT_ID", + "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", +) + android { namespace = "org.libremail" compileSdk = 37 @@ -42,6 +49,8 @@ android { 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 } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f69c0a..958ae71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,5 +58,21 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + + + + + diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt new file mode 100644 index 0000000..ebf8ad1 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -0,0 +1,119 @@ +// 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 javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +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 + +/** + * Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. + * + * One consent requests the `outlook.office.com` IMAP **and** SMTP scopes. Because both live + * under a single resource, the resulting access token authenticates IMAP receive and SMTP send + * over SASL XOAUTH2 — no second token or Graph call is needed. The "common" tenant endpoints + * accept both personal Microsoft accounts (outlook.com/hotmail) and work/school (Microsoft 365). + */ +@Singleton +class OutlookAuthManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"), + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"), + ) + + /** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */ + val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank() + + fun createAuthIntent(): Intent { + val request = AuthorizationRequest.Builder( + serviceConfig, + BuildConfig.OUTLOOK_OAUTH_CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI), + ) + .setScope( + "openid email offline_access " + + "https://outlook.office.com/IMAP.AccessAsUser.All " + + "https://outlook.office.com/SMTP.Send", + ) + .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()) + } finally { + service.dispose() + } + } + + /** Microsoft id tokens carry the address in `email`, falling back to `preferred_username`. */ + 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)) + val claims = JSONObject(json) + claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null } + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 2f50cca..48d03d4 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -55,6 +55,19 @@ class AccountRepositoryImpl @Inject constructor( folders } + override suspend fun addOutlookAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = runCatching { + val account = Account.outlook(email) + val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) + accountDao.upsert(account.toEntity()) + credentialStore.saveSecret(account.id, authStateJson) + syncScheduler.syncNow() + folders + } + override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) 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 e7bd652..75e1150 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -3,7 +3,9 @@ package org.libremail.data.sync import javax.inject.Inject import javax.inject.Singleton +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 import org.libremail.data.security.CredentialStore @@ -17,25 +19,32 @@ import org.libremail.domain.model.SmtpParams class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, private val gmailAuthManager: GmailAuthManager, + private val outlookAuthManager: OutlookAuthManager, ) { suspend fun imapParamsFor(account: Account): ImapConnectionParams = - account.toImapParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) suspend fun smtpParamsFor(account: Account): SmtpParams = - account.toSmtpParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) private suspend fun resolveSecret(account: Account): String { val stored = credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") return when (account.authType) { AuthType.PASSWORD_IMAP -> stored - AuthType.OAUTH_GMAIL -> { - val fresh = gmailAuthManager.freshAccessToken(stored) - if (fresh.authStateJson != stored) { - credentialStore.saveSecret(account.id, fresh.authStateJson) - } - fresh.accessToken - } + AuthType.OAUTH_GMAIL -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshAccessToken) } } + + /** Refreshes an OAuth access token, persisting the updated AuthState when it changes. */ + private suspend fun refreshedToken( + accountId: String, + stored: String, + refresh: suspend (String) -> FreshToken, + ): String { + val fresh = refresh(stored) + if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson) + return fresh.accessToken + } } 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 f731bf0..e68ebde 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -6,6 +6,9 @@ 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, + /** Generic IMAP/SMTP with a password or app-password. */ PASSWORD_IMAP, } @@ -28,5 +31,15 @@ data class Account( imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), smtp = ServerConfig("smtp.gmail.com", 465, MailSecurity.SSL_TLS), ) + + /** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */ + fun outlook(email: String, displayName: String = email): Account = Account( + id = "outlook:$email", + email = email, + displayName = displayName.ifBlank { email }, + authType = AuthType.OAUTH_OUTLOOK, + imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS), + ) } } diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt index 847fbaf..3967431 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt @@ -18,5 +18,8 @@ interface AccountRepository { /** Verify (via XOAUTH2), then persist, a Gmail account. Returns the folders found. */ suspend fun addGmailAccount(email: String, accessToken: String, authStateJson: String): Result> + /** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */ + suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result> + suspend fun deleteAccount(id: String) } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index 5befb0f..b060378 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -60,6 +60,10 @@ fun AccountSetupScreen( ActivityResultContracts.StartActivityForResult(), ) { result -> viewModel.onGmailResult(result.data) } + val outlookLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> viewModel.onOutlookResult(result.data) } + LaunchedEffect(state.status) { if (state.status == SetupStatus.DONE) onAccountAdded() } @@ -118,6 +122,14 @@ fun AccountSetupScreen( Text(stringResource(R.string.account_setup_gmail)) } Spacer(Modifier.height(12.dp)) + Button( + onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_outlook)) + } + Spacer(Modifier.height(12.dp)) OutlinedButton( onClick = onManualSetup, enabled = !busy, diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 8eb2b5b..0248f57 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.auth.GmailAuthManager +import org.libremail.auth.OutlookAuthManager import org.libremail.domain.repository.AccountRepository /** Stage of an account-setup attempt, shared by the Gmail and manual flows. */ @@ -25,6 +26,7 @@ data class AccountSetupUiState( @HiltViewModel class AccountSetupViewModel @Inject constructor( private val authManager: GmailAuthManager, + private val outlookAuthManager: OutlookAuthManager, private val accountRepository: AccountRepository, ) : ViewModel() { @@ -52,5 +54,26 @@ class AccountSetupViewModel @Inject constructor( } } + val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured + + fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent() + + fun onOutlookResult(data: Intent?) { + if (data == null) { + _state.update { it.copy(error = "Microsoft sign-in was cancelled") } + return + } + viewModelScope.launch { + _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + runCatching { + val oauth = outlookAuthManager.exchangeToken(data) + accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() + }.fold( + onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } }, + ) + } + } + fun consumeError() = _state.update { it.copy(error = null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8cf5cf..43208d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,7 @@ Sign in with Google + Sign in with Microsoft Other (IMAP/SMTP) Choose how you want to connect your mailbox. Add your Google OAuth client ID first — see the README