feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)

Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt
die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider
oder gegen einen aelteren Server bleibt der Screen unveraendert.

Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einem
Chrome Custom Tab (nie ein WebView), ohne Client Secret. Das ID-Token wird
anschliessend an POST /api/auth/oidc/exchange gegen ein Calendarr-Token
getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der
bestehende Session-Pfad unveraendert weiterlaeuft.

- OidcAuthenticator kapselt Discovery, PKCE-Request und Code-Tausch.
- Die angefragten Scopes kommen vom Server (mobile_scopes) — ein Scope, den
  der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen.
- AuthState mit dem Refresh-Token liegt in den EncryptedSharedPreferences,
  nie im Klartext, und wird beim Logout mitgeloescht.
- Provider und Client-ID werden vor dem Browser-Start persistiert, weil der
  Custom Tab in einer eigenen Task laeuft und dieser Prozess dabei getoetet
  werden kann; der Nonce kommt aus AuthorizationResponse.request, das AppAuth
  ueber den Redirect zurueckreicht.
- Abbruch im Browser meldet AppAuth als regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW und wird still behandelt, nicht als Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 18:25:15 +02:00
co-authored by Claude Opus 5
parent aeb50c593c
commit 8b0af0f44a
10 changed files with 482 additions and 5 deletions
+10
View File
@@ -29,6 +29,13 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
// AppAuth merges its RedirectUriReceiverActivity into the manifest and
// needs the custom scheme for the OIDC redirect. Reverse-DNS on the
// application id, per RFC 8252; the same value must be registered as a
// redirect URI in the identity provider:
// com.scarriffle.calendarr:/oauth2redirect
manifestPlaceholders["appAuthRedirectScheme"] = "com.scarriffle.calendarr"
} }
signingConfigs { signingConfigs {
@@ -109,6 +116,9 @@ dependencies {
// Secure credential storage // Secure credential storage
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
// OIDC / SSO — Authorization Code Flow with PKCE in a Custom Tab
implementation("net.openid:appauth:0.11.1")
// Async // Async
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
@@ -1,6 +1,7 @@
package com.scarriffle.calendarr.data package com.scarriffle.calendarr.data
import com.scarriffle.calendarr.data.remote.ApiException import com.scarriffle.calendarr.data.remote.ApiException
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.ApiProvider import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.data.remote.UnauthorizedException import com.scarriffle.calendarr.data.remote.UnauthorizedException
@@ -18,6 +19,7 @@ import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.OidcProvider
import com.scarriffle.calendarr.domain.model.UserProfile import com.scarriffle.calendarr.domain.model.UserProfile
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates import com.scarriffle.calendarr.util.Dates
@@ -100,10 +102,89 @@ class CalendarRepository @Inject constructor(
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code())) if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = username)
}
// ---- Single sign-on (OpenID Connect) ----
/** Providers configured on the server. Empty list when SSO is off. */
suspend fun oidcProviders(baseUrl: String): List<OidcProvider> = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).oidcProviders()
if (!resp.isSuccessful) return@withContext emptyList()
val raw = resp.body()?.string() ?: return@withContext emptyList()
runCatching {
val json = JSONObject(raw)
if (!json.optBoolean("enabled", false)) return@runCatching emptyList<OidcProvider>()
val arr = json.optJSONArray("providers") ?: return@runCatching emptyList<OidcProvider>()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
// Without a mobile client id the app cannot start a flow.
val clientId = o.optString("mobile_client_id").takeIf { it.isNotBlank() }
?: return@mapNotNull null
// Without an issuer the flow can only fail later with a
// misleading "unreachable" — drop the provider here instead.
val issuer = o.optString("issuer").takeIf { it.isNotBlank() }
?: return@mapNotNull null
OidcProvider(
key = o.optString("key"),
name = o.optString("name").takeIf { it.isNotBlank() } ?: o.optString("key"),
issuer = issuer,
// The server decides the scopes (it knows whether the
// provider grants offline_access); the app never adds any.
scopes = o.optString("mobile_scopes").takeIf { it.isNotBlank() }
?: o.optString("scopes").takeIf { it.isNotBlank() }
?: "openid profile email",
mobileClientId = clientId,
)
}
}.getOrDefault(emptyList())
}
/**
* Trade a verified provider ID token for a Calendarr token.
*
* The app is a public client: the request carries no secret, only the id
* token the provider issued for [clientId] plus the [nonce] AppAuth bound
* into the flow.
*/
suspend fun exchangeOidc(
baseUrl: String,
provider: String,
clientId: String,
idToken: String,
accessToken: String?,
nonce: String?,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"provider" to provider,
"client_id" to clientId,
"id_token" to idToken,
"access_token" to accessToken,
"nonce" to nonce,
)
val resp = apiProvider.apiFor(baseUrl).oidcExchange(body)
if (!resp.isSuccessful) {
// The server answers with a stable slug in `detail`; map the ones a
// user can act on, and fall back to the raw detail otherwise.
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull().orEmpty()
throw OidcException(detail.takeIf { it.isNotBlank() } ?: "oidc_failed")
}
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = null)
}
/** Shared tail of [login] and [exchangeOidc]: read the user, persist, reset. */
private fun persistLogin(baseUrl: String, json: JSONObject, fallbackUsername: String?): LoginResult {
val token = json.optString("access_token").takeIf { it.isNotBlank() } val token = json.optString("access_token").takeIf { it.isNotBlank() }
?: throw ApiException("Antwort konnte nicht verarbeitet werden") ?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val user = json.optJSONObject("user") val user = json.optJSONObject("user")
val uname = user?.optString("username") ?: username // A blank username would be persisted as a blank display name too, so
// refuse rather than store a nameless identity.
val uname = user?.optString("username")?.takeIf { it.isNotBlank() }
?: fallbackUsername
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val isAdmin = user?.optBoolean("is_admin", false) ?: false val isAdmin = user?.optBoolean("is_admin", false) ?: false
val uid = user?.optInt("id", 0) ?: 0 val uid = user?.optInt("id", 0) ?: 0
val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname
@@ -111,7 +192,7 @@ class CalendarRepository @Inject constructor(
credentialStore.serverUrl = ApiProvider.normalize(baseUrl) credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin, uid, displayName) credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
apiProvider.invalidate() apiProvider.invalidate()
LoginResult(token, uname, isAdmin) return LoginResult(token, uname, isAdmin)
} }
// ---- Settings / profile ---- // ---- Settings / profile ----
@@ -54,6 +54,24 @@ class CredentialStore @Inject constructor(
get() = prefs.getString(KEY_DISPLAY_NAME, null) get() = prefs.getString(KEY_DISPLAY_NAME, null)
set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply() set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply()
/**
* Serialised AppAuth [net.openid.appauth.AuthState] for the SSO session —
* holds the provider's refresh token, so it must stay in the encrypted
* store and never in plain preferences.
*/
var oidcState: String?
get() = prefs.getString(KEY_OIDC_STATE, null)
set(value) = prefs.edit().putString(KEY_OIDC_STATE, value).apply()
/** Provider key and client id the current SSO session was obtained with. */
var oidcProvider: String?
get() = prefs.getString(KEY_OIDC_PROVIDER, null)
set(value) = prefs.edit().putString(KEY_OIDC_PROVIDER, value).apply()
var oidcClientId: String?
get() = prefs.getString(KEY_OIDC_CLIENT_ID, null)
set(value) = prefs.edit().putString(KEY_OIDC_CLIENT_ID, value).apply()
/** True once a server URL has been entered (setup step complete). */ /** True once a server URL has been entered (setup step complete). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
@@ -70,12 +88,24 @@ class CredentialStore @Inject constructor(
.apply() .apply()
} }
/** Persist the SSO session alongside the Calendarr token. */
fun saveOidcSession(state: String, provider: String, clientId: String) {
prefs.edit()
.putString(KEY_OIDC_STATE, state)
.putString(KEY_OIDC_PROVIDER, provider)
.putString(KEY_OIDC_CLIENT_ID, clientId)
.apply()
}
/** Clear the token (logout) but keep the server URL. */ /** Clear the token (logout) but keep the server URL. */
fun clearToken() { fun clearToken() {
prefs.edit() prefs.edit()
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_USERNAME) .remove(KEY_USERNAME)
.remove(KEY_IS_ADMIN) .remove(KEY_IS_ADMIN)
.remove(KEY_OIDC_STATE)
.remove(KEY_OIDC_PROVIDER)
.remove(KEY_OIDC_CLIENT_ID)
.apply() .apply()
} }
@@ -91,5 +121,8 @@ class CredentialStore @Inject constructor(
const val KEY_IS_ADMIN = "is_admin" const val KEY_IS_ADMIN = "is_admin"
const val KEY_USER_ID = "user_id" const val KEY_USER_ID = "user_id"
const val KEY_DISPLAY_NAME = "display_name" const val KEY_DISPLAY_NAME = "display_name"
const val KEY_OIDC_STATE = "oidc_state"
const val KEY_OIDC_PROVIDER = "oidc_provider"
const val KEY_OIDC_CLIENT_ID = "oidc_client_id"
} }
} }
@@ -0,0 +1,120 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.Intent
import com.scarriffle.calendarr.domain.model.OidcProvider
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 net.openid.appauth.TokenResponse
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** The app is registered as a *public* client — no secret is ever sent. */
private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect"
/**
* Drives the OpenID Connect Authorization Code Flow with PKCE against the
* identity provider, using AppAuth and a Chrome Custom Tab.
*
* Deliberately never uses a WebView: a custom tab keeps the credentials in the
* browser's own security context and lets the provider's session cookies work.
*/
@Singleton
class OidcAuthenticator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Volatile
private var service: AuthorizationService? = null
private fun service(): AuthorizationService =
service ?: synchronized(this) {
service ?: AuthorizationService(context).also { service = it }
}
/** Fetch the provider's OpenID configuration from its issuer URL. */
suspend fun discover(provider: OidcProvider): AuthorizationServiceConfiguration =
suspendCancellableCoroutine { cont ->
AuthorizationServiceConfiguration.fetchFromIssuer(
android.net.Uri.parse(provider.issuer)
) { config, ex ->
when {
config != null -> cont.resume(config)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
/**
* Build the authorization request. AppAuth generates the PKCE verifier and
* the nonce itself; both are carried on the returned request, and the nonce
* has to travel to our server later so it can verify the ID token.
*/
fun buildRequest(
config: AuthorizationServiceConfiguration,
provider: OidcProvider,
): AuthorizationRequest {
// Exactly what the server asked for — including offline_access when it
// configured that. A scope the provider does not know would make it
// reject the whole authorization request.
val scopes = buildSet {
addAll(provider.scopes.split(" ").filter { it.isNotBlank() })
add("openid")
}
return AuthorizationRequest.Builder(
config,
requireNotNull(provider.mobileClientId) { "provider has no mobile client id" },
ResponseTypeValues.CODE,
android.net.Uri.parse(REDIRECT_URI),
)
.setScopes(scopes)
.build()
}
/** Intent that opens the provider's login page in a Custom Tab. */
fun authorizationIntent(request: AuthorizationRequest): Intent =
service().getAuthorizationRequestIntent(request)
/**
* Complete the code exchange with the provider.
*
* PKCE means no client secret is involved: the verifier AppAuth kept from
* [buildRequest] is what proves this is the same app that started the flow.
*/
suspend fun exchangeCode(data: Intent): Pair<AuthorizationResponse, TokenResponse> {
val response = AuthorizationResponse.fromIntent(data)
val error = AuthorizationException.fromIntent(data)
if (response == null) throw error ?: AuthorizationException.GeneralErrors.NETWORK_ERROR
val tokens: TokenResponse = suspendCancellableCoroutine { cont ->
service().performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex ->
when {
tokens != null -> cont.resume(tokens)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
return response to tokens
}
/**
* Serialised AuthState (including the refresh token) for secure storage.
*
* Both arguments are non-null on purpose: `AuthState.update` asserts that
* exactly one of (response, exception) is set and throws otherwise.
*/
fun stateFor(response: AuthorizationResponse, tokens: TokenResponse): String =
AuthState().apply {
update(response, null)
update(tokens, null)
}.jsonSerializeString()
}
@@ -32,3 +32,11 @@ fun Response<*>.ensureSuccess() {
throw ApiException(errorDetail(errorBody(), code())) throw ApiException(errorDetail(errorBody(), code()))
} }
} }
/**
* An SSO exchange the server refused. [slug] is the stable machine-readable
* reason (e.g. `oidc_account_not_linked`), which the UI maps to a translated
* message; unknown slugs fall back to a generic one.
*/
class OidcException(val slug: String) : Exception(slug)
@@ -37,6 +37,15 @@ interface CalendarrApi {
@GET("api/auth/setup-required") @GET("api/auth/setup-required")
suspend fun setupRequired(): Response<ResponseBody> suspend fun setupRequired(): Response<ResponseBody>
/** Configured OIDC providers. Public — used before login. */
@GET("api/auth/oidc/providers")
suspend fun oidcProviders(): Response<ResponseBody>
/** Trade a provider ID token for a Calendarr token. Same response shape as
* [login]. Public client: never send a client secret. */
@POST("api/auth/oidc/exchange")
suspend fun oidcExchange(@Body body: RequestBody): Response<ResponseBody>
@POST("api/auth/login") @POST("api/auth/login")
suspend fun login(@Body body: RequestBody): Response<ResponseBody> suspend fun login(@Body body: RequestBody): Response<ResponseBody>
@@ -0,0 +1,13 @@
package com.scarriffle.calendarr.domain.model
/**
* One identity provider configured on the Calendarr server, as returned by
* `GET /api/auth/oidc/providers`.
*/
data class OidcProvider(
val key: String,
val name: String,
val issuer: String,
val scopes: String,
val mobileClientId: String?,
)
@@ -210,6 +210,21 @@ object L10n {
"auth.password" to "Passwort", "auth.login" to "Anmelden", "auth.password" to "Passwort", "auth.login" to "Anmelden",
"auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben", "auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben",
"auth.back" to "Zurück", "auth.back" to "Zurück",
// Single sign-on (OpenID Connect)
"sso.or" to "oder", "sso.login_with" to "Mit %s anmelden",
"sso.err.generic" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_failed" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_provider_unreachable" to "Anmeldedienst nicht erreichbar",
"sso.err.oidc_no_browser" to "Kein Browser installiert — für die Anmeldung wird einer benötigt",
"sso.err.oidc_account_not_linked" to "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.",
"sso.err.oidc_signup_disabled" to "Automatische Kontoerstellung ist deaktiviert",
"sso.err.oidc_domain_not_allowed" to "Diese E-Mail-Domain ist nicht zugelassen",
"sso.err.oidc_email_conflict" to "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto",
"sso.err.oidc_email_not_verified" to "E-Mail-Adresse beim Anbieter nicht bestätigt",
"sso.err.oidc_no_email" to "Der Anbieter hat keine E-Mail-Adresse übermittelt",
"sso.err.oidc_unknown_client" to "Diese App ist beim Anbieter nicht registriert",
"sso.err.oidc_invalid_token" to "Das Anmelde-Token ist ungültig",
"sso.err.oidc_no_id_token" to "Der Anbieter hat kein ID-Token geliefert",
), ),
"en" to mapOf( "en" to mapOf(
"nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done", "nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done",
@@ -391,6 +406,21 @@ object L10n {
"auth.password" to "Password", "auth.login" to "Sign in", "auth.password" to "Password", "auth.login" to "Sign in",
"auth.totp" to "2FA code", "auth.remember" to "Stay signed in", "auth.totp" to "2FA code", "auth.remember" to "Stay signed in",
"auth.back" to "Back", "auth.back" to "Back",
// Single sign-on (OpenID Connect)
"sso.or" to "or", "sso.login_with" to "Sign in with %s",
"sso.err.generic" to "Sign-in failed",
"sso.err.oidc_failed" to "Sign-in failed",
"sso.err.oidc_provider_unreachable" to "Sign-in service unreachable",
"sso.err.oidc_no_browser" to "No browser installed — signing in requires one",
"sso.err.oidc_account_not_linked" to "No linked Calendarr account. Sign in locally and link it in Settings.",
"sso.err.oidc_signup_disabled" to "Automatic account creation is disabled",
"sso.err.oidc_domain_not_allowed" to "This email domain is not allowed",
"sso.err.oidc_email_conflict" to "That email address already belongs to another account",
"sso.err.oidc_email_not_verified" to "Email address not verified at the provider",
"sso.err.oidc_no_email" to "The provider did not supply an email address",
"sso.err.oidc_unknown_client" to "This app is not registered with the provider",
"sso.err.oidc_invalid_token" to "The sign-in token is invalid",
"sso.err.oidc_no_id_token" to "The provider did not return an ID token",
), ),
) )
} }
@@ -1,5 +1,7 @@
package com.scarriffle.calendarr.ui.auth package com.scarriffle.calendarr.ui.auth
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -7,16 +9,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.CredentialStore import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.remote.ApiProvider import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationException
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val credentialStore: CredentialStore, private val credentialStore: CredentialStore,
private val oidc: OidcAuthenticator,
) : ViewModel() { ) : ViewModel() {
// Server setup // Server setup
@@ -43,10 +50,26 @@ class AuthViewModel @Inject constructor(
var loginError by mutableStateOf<String?>(null) var loginError by mutableStateOf<String?>(null)
private set private set
// Single sign-on
var ssoProviders by mutableStateOf<List<OidcProvider>>(emptyList())
private set
/** Key of the provider whose flow is running, or null. Per-provider so a
* second button does not also show a spinner. */
var ssoBusyKey by mutableStateOf<String?>(null)
private set
/** Stable server error slug (e.g. `oidc_account_not_linked`). The screen
* turns it into a message; the ViewModel cannot call the translator. */
var ssoErrorSlug by mutableStateOf<String?>(null)
private set
private var loadingProviders = false
fun onServerUrlChange(v: String) { serverUrl = v; setupError = null } fun onServerUrlChange(v: String) { serverUrl = v; setupError = null }
fun onUsernameChange(v: String) { username = v; loginError = null } fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null }
fun onPasswordChange(v: String) { password = v; loginError = null } fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null }
fun onTotpChange(v: String) { totpCode = v; loginError = null } fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null }
fun onRememberChange(v: Boolean) { rememberMe = v } fun onRememberChange(v: Boolean) { rememberMe = v }
fun checkServer(onConfigured: () -> Unit) { fun checkServer(onConfigured: () -> Unit) {
@@ -65,6 +88,108 @@ class AuthViewModel @Inject constructor(
} }
} }
/** Load the configured providers. Silently yields an empty list when SSO
* is off or the server is older — the password form stays as it is. */
fun loadSsoProviders() {
val base = credentialStore.serverUrl ?: return
if (ssoProviders.isNotEmpty() || loadingProviders) return
loadingProviders = true
viewModelScope.launch {
runCatching { repository.oidcProviders(base) }
.onSuccess { ssoProviders = it }
loadingProviders = false
}
}
/**
* Start an SSO login: fetch the provider's configuration, build the PKCE
* request and open the Custom Tab.
*
* The provider key and client id are persisted before launching, because
* the browser runs in its own task and this process may well be killed
* while it is in the foreground.
*/
fun startSso(provider: OidcProvider, launch: (Intent) -> Unit) {
val clientId = provider.mobileClientId
if (clientId == null) { ssoErrorSlug = "oidc_unknown_client"; return }
viewModelScope.launch {
ssoBusyKey = provider.key
loginError = null
ssoErrorSlug = null
// `launch` has to stay inside runCatching: the result launcher is
// unregistered if the user leaves the screen while discovery is in
// flight, and the resulting IllegalStateException would otherwise
// escape viewModelScope and take the app down.
runCatching {
val config = oidc.discover(provider)
val request = oidc.buildRequest(config, provider)
credentialStore.oidcProvider = provider.key
credentialStore.oidcClientId = clientId
launch(oidc.authorizationIntent(request))
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = when (e) {
// No browser at all needs a different remedy than "the
// login service is down".
is ActivityNotFoundException -> "oidc_no_browser"
else -> "oidc_provider_unreachable"
}
}
}
}
/** Handle the browser's return: exchange the code, then trade the ID token. */
fun finishSso(data: Intent?, onSuccess: () -> Unit) {
if (data == null) { ssoBusyKey = null; return }
// AppAuth reports a user-cancelled flow as a normal result carrying
// USER_CANCELED_AUTH_FLOW — backing out of the browser is not an error.
val failure = AuthorizationException.fromIntent(data)
if (failure == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW) {
ssoBusyKey = null
return
}
val providerKey = credentialStore.oidcProvider
val clientId = credentialStore.oidcClientId
if (providerKey == null || clientId == null) {
ssoBusyKey = null
ssoErrorSlug = "oidc_failed"
return
}
viewModelScope.launch {
runCatching {
val (response, tokens) = oidc.exchangeCode(data)
val idToken = tokens.idToken ?: throw OidcException("oidc_no_id_token")
val result = repository.exchangeOidc(
baseUrl = credentialStore.serverUrl ?: error("no server"),
provider = providerKey,
clientId = clientId,
idToken = idToken,
accessToken = tokens.accessToken,
// AppAuth round-trips the original request through the
// redirect, so the nonce survives process death.
nonce = response.request.nonce,
)
// Keep the provider's refresh token so the session can be
// renewed later without another trip through the browser.
credentialStore.saveOidcSession(
state = oidc.stateFor(response, tokens),
provider = providerKey,
clientId = clientId,
)
result
}.onSuccess {
ssoBusyKey = null
onSuccess()
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = if (e is OidcException) e.slug else "oidc_failed"
}
}
}
fun login(onSuccess: () -> Unit) { fun login(onSuccess: () -> Unit) {
val base = credentialStore.serverUrl ?: return val base = credentialStore.serverUrl ?: return
if (username.isBlank() || password.isBlank()) { if (username.isBlank() || password.isBlank()) {
@@ -21,8 +21,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Divider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -40,6 +45,14 @@ fun LoginScreen(
onBack: () -> Unit, onBack: () -> Unit,
vm: AuthViewModel = hiltViewModel(), vm: AuthViewModel = hiltViewModel(),
) { ) {
// Browser round-trip for SSO. AppAuth returns the authorization response
// as an Activity result, so nothing has to be wired into MainActivity.
val ssoLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result -> vm.finishSso(result.data, onLoggedIn) }
LaunchedEffect(Unit) { vm.loadSsoProviders() }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) { Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
@@ -96,6 +109,14 @@ fun LoginScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} }
vm.ssoErrorSlug?.let { slug ->
// Unknown slugs fall through to the generic message rather than
// showing the raw key.
val key = "sso.err.$slug"
val msg = tr(key).takeIf { it != key } ?: tr("sso.err.generic")
Spacer(Modifier.height(8.dp))
Text(msg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Button( Button(
onClick = { vm.login(onLoggedIn) }, onClick = { vm.login(onLoggedIn) },
@@ -108,6 +129,33 @@ fun LoginScreen(
Text(tr("auth.login")) Text(tr("auth.login"))
} }
} }
if (vm.ssoProviders.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Divider(modifier = Modifier.weight(1f))
Text(
tr("sso.or"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
vm.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = { vm.startSso(provider) { intent -> ssoLauncher.launch(intent) } },
enabled = vm.ssoBusyKey == null && !vm.loggingIn,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
if (vm.ssoBusyKey == provider.key) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("sso.login_with", provider.name))
}
}
}
}
TextButton(onClick = onBack) { Text(tr("server.switch")) } TextButton(onClick = onBack) { Text(tr("server.switch")) }
} }
} }