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