Archived
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:
co-authored by
Claude Opus 5
parent
aeb50c593c
commit
8b0af0f44a
@@ -29,6 +29,13 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
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 {
|
||||
@@ -109,6 +116,9 @@ dependencies {
|
||||
// Secure credential storage
|
||||
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
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
|
||||
@@ -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")) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user