Add Kotlin Multiplatform mobile client

A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.

deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 10:03:11 +02:00
co-authored by Claude Sonnet 5
parent 7ac1f7bad8
commit 20d01a0eb2
50 changed files with 2200 additions and 0 deletions
@@ -0,0 +1,129 @@
package com.musify.app.auth
import com.musify.app.AppConfig
import com.musify.app.httpClient
import com.musify.app.util.Base64Url
import io.ktor.client.call.body
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.http.Parameters
import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Requesting `offline_access` gets a refresh token back so the session survives an access-token expiry. */
private const val SCOPES = "openid profile email offline_access"
class AuthController(
private val tokenStore: TokenStore = TokenStore(),
private val browserLauncher: BrowserAuthLauncher = BrowserAuthLauncher()
) {
private val _state = MutableStateFlow<AuthState>(AuthState.SignedOut)
val state: StateFlow<AuthState> = _state.asStateFlow()
private var discovery: OidcDiscoveryDocument? = null
/** Call once at startup: restores a still-valid session without touching the browser. */
suspend fun restoreSession() {
val stored = tokenStore.load() ?: return
if (stored.expiresAtEpochSeconds > nowEpochSeconds() + 30) {
_state.value = AuthState.SignedIn(stored.accessToken, stored.displayName)
} else {
tokenStore.clear()
}
}
/** Opens Zitadel's hosted login (with a "Regístrate" link) and completes the PKCE exchange. */
suspend fun signIn() {
_state.value = AuthState.SigningIn
try {
val doc = discoveryDocument()
val verifier = Pkce.generateCodeVerifier()
val challenge = Pkce.codeChallenge(verifier)
val expectedState = Pkce.randomState()
val authorizeUrl = buildString {
append(doc.authorizationEndpoint)
append("?client_id=").append(AppConfig.authClientId.encodeURLParameter())
append("&response_type=code")
append("&redirect_uri=").append(AppConfig.nativeRedirectUri.encodeURLParameter())
append("&scope=").append(SCOPES.encodeURLParameter())
append("&code_challenge=").append(challenge)
append("&code_challenge_method=S256")
append("&state=").append(expectedState)
}
val redirected = browserLauncher.launch(authorizeUrl, AppConfig.nativeRedirectUri)
val params = parseQuery(redirected.substringAfter('?', ""))
check(params["state"] == expectedState) { "El estado OAuth no coincide; intenta iniciar sesión de nuevo." }
params["error"]?.let { error("Zitadel devolvió un error: $it ${params["error_description"].orEmpty()}") }
val code = params["code"] ?: error("No se recibió el código de autorización.")
val tokenResponse: TokenResponse = httpClient.submitForm(
url = doc.tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("code", code)
append("redirect_uri", AppConfig.nativeRedirectUri)
append("client_id", AppConfig.authClientId)
append("code_verifier", verifier)
}
).body()
val claims = decodeIdTokenClaims(tokenResponse.idToken)
val displayName = claims?.get("name") ?: claims?.get("preferred_username") ?: claims?.get("email")
tokenStore.save(
StoredTokens(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
expiresAtEpochSeconds = nowEpochSeconds() + tokenResponse.expiresInSeconds,
displayName = displayName
)
)
_state.value = AuthState.SignedIn(tokenResponse.accessToken, displayName)
} catch (t: Throwable) {
_state.value = AuthState.Error(t.message ?: "No se pudo iniciar sesión.")
}
}
suspend fun signOut() {
tokenStore.clear()
_state.value = AuthState.SignedOut
}
fun currentAccessToken(): String? = (_state.value as? AuthState.SignedIn)?.accessToken
private suspend fun discoveryDocument(): OidcDiscoveryDocument {
discovery?.let { return it }
val doc: OidcDiscoveryDocument = httpClient
.get("${AppConfig.authIssuer}/.well-known/openid-configuration")
.body()
discovery = doc
return doc
}
}
private fun parseQuery(query: String): Map<String, String> =
query.split("&")
.filter { it.isNotBlank() }
.associate { pair ->
val idx = pair.indexOf('=')
if (idx < 0) pair to "" else pair.take(idx) to pair.substring(idx + 1).decodeURLQueryComponent()
}
private fun decodeIdTokenClaims(idToken: String?): Map<String, String>? {
val payload = idToken?.split(".")?.getOrNull(1) ?: return null
return try {
Json.parseToJsonElement(Base64Url.decodeToString(payload)).jsonObject
.mapValues { it.value.jsonPrimitive.content }
} catch (t: Throwable) {
null
}
}
@@ -0,0 +1,40 @@
package com.musify.app.auth
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** `GET {issuer}/.well-known/openid-configuration` (OIDC discovery). */
@Serializable
data class OidcDiscoveryDocument(
val issuer: String,
@SerialName("authorization_endpoint") val authorizationEndpoint: String,
@SerialName("token_endpoint") val tokenEndpoint: String,
@SerialName("end_session_endpoint") val endSessionEndpoint: String? = null
)
/** `POST {token_endpoint}` response (RFC 6749 §5.1). */
@Serializable
data class TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("id_token") val idToken: String? = null,
@SerialName("expires_in") val expiresInSeconds: Long = 0,
@SerialName("token_type") val tokenType: String = "Bearer"
)
/** What [TokenStore] persists locally between app launches. */
@Serializable
data class StoredTokens(
val accessToken: String,
val refreshToken: String?,
val expiresAtEpochSeconds: Long,
val displayName: String?
)
/** Where the demo's UI is at, driven by [AuthController.state]. */
sealed interface AuthState {
data object SignedOut : AuthState
data object SigningIn : AuthState
data class SignedIn(val accessToken: String, val displayName: String?) : AuthState
data class Error(val message: String) : AuthState
}
@@ -0,0 +1,20 @@
package com.musify.app.auth
/**
* Opens the system/in-app browser at [authorizeUrl] and suspends until Zitadel
* redirects back to [redirectUri], returning the full redirect URL (including
* its `?code=...&state=...` or `?error=...` query string).
*
* Zitadel's hosted login page (opened here) is also where "¿No tienes cuenta?
* Regístrate" lives — the demo doesn't need a separate register screen or
* endpoint, the same Authorization Code + PKCE round trip covers both.
*
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
* - Android: Chrome Custom Tabs; the redirect is caught by
* [AuthCallbackActivity] via the `musify://callback` intent filter.
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
*/
expect class BrowserAuthLauncher() {
suspend fun launch(authorizeUrl: String, redirectUri: String): String
}
@@ -0,0 +1,25 @@
package com.musify.app.auth
import com.musify.app.util.Base64Url
import kotlin.random.Random
/** SHA-256, implemented per platform (java.security on JVM, CommonCrypto on iOS). */
expect fun sha256(input: ByteArray): ByteArray
/** Wall-clock seconds since epoch, needed to persist token expiry across restarts. */
expect fun nowEpochSeconds(): Long
/** RFC 7636 PKCE helpers for the Authorization Code flow the native clients use. */
object Pkce {
private val unreservedChars = (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~'))
fun generateCodeVerifier(): String {
val random = Random.Default
return (1..64).map { unreservedChars[random.nextInt(unreservedChars.size)] }.joinToString("")
}
fun codeChallenge(verifier: String): String =
Base64Url.encode(sha256(verifier.encodeToByteArray()))
fun randomState(): String = generateCodeVerifier().take(24)
}
@@ -0,0 +1,8 @@
package com.musify.app.auth
/** Local persistence for [StoredTokens] — a file on Desktop, DataStore on Android, NSUserDefaults on iOS. */
expect class TokenStore() {
suspend fun save(tokens: StoredTokens)
suspend fun load(): StoredTokens?
suspend fun clear()
}