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"
|
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")) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user