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
@@ -1,6 +1,7 @@
package com.scarriffle.calendarr.data
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.TwoFactorRequiredException
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.ICalSubscription
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.WritableCalendar
import com.scarriffle.calendarr.util.Dates
@@ -100,10 +102,89 @@ class CalendarRepository @Inject constructor(
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
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() }
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
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 uid = user?.optInt("id", 0) ?: 0
val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname
@@ -111,7 +192,7 @@ class CalendarRepository @Inject constructor(
credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
apiProvider.invalidate()
LoginResult(token, uname, isAdmin)
return LoginResult(token, uname, isAdmin)
}
// ---- Settings / profile ----
@@ -54,6 +54,24 @@ class CredentialStore @Inject constructor(
get() = prefs.getString(KEY_DISPLAY_NAME, null)
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). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
@@ -70,12 +88,24 @@ class CredentialStore @Inject constructor(
.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. */
fun clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_IS_ADMIN)
.remove(KEY_OIDC_STATE)
.remove(KEY_OIDC_PROVIDER)
.remove(KEY_OIDC_CLIENT_ID)
.apply()
}
@@ -91,5 +121,8 @@ class CredentialStore @Inject constructor(
const val KEY_IS_ADMIN = "is_admin"
const val KEY_USER_ID = "user_id"
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()))
}
}
/**
* 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")
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")
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.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben",
"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(
"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.totp" to "2FA code", "auth.remember" to "Stay signed in",
"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
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -7,16 +9,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
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.OidcException
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationException
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: CalendarRepository,
private val credentialStore: CredentialStore,
private val oidc: OidcAuthenticator,
) : ViewModel() {
// Server setup
@@ -43,10 +50,26 @@ class AuthViewModel @Inject constructor(
var loginError by mutableStateOf<String?>(null)
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 onUsernameChange(v: String) { username = v; loginError = null }
fun onPasswordChange(v: String) { password = v; loginError = null }
fun onTotpChange(v: String) { totpCode = v; loginError = null }
fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null }
fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null }
fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null }
fun onRememberChange(v: Boolean) { rememberMe = v }
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) {
val base = credentialStore.serverUrl ?: return
if (username.isBlank() || password.isBlank()) {
@@ -21,8 +21,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
@@ -40,6 +45,14 @@ fun LoginScreen(
onBack: () -> Unit,
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)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
IconButton(onClick = onBack) {
@@ -96,6 +109,14 @@ fun LoginScreen(
Spacer(Modifier.height(8.dp))
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))
Button(
onClick = { vm.login(onLoggedIn) },
@@ -108,6 +129,33 @@ fun LoginScreen(
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")) }
}
}