From 7b8f3f12229e2b73804331f0ec8ab33279c40afc Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 15:06:25 +0200 Subject: [PATCH] Use a reverse-domain redirect scheme for the native app - Android/iOS now return to cat.ikerdemo.musify:/callback instead of musify://callback: a private-use scheme based on a domain we own (RFC 8252 section 7.1), so another app is unlikely to claim it. - The Musify Native app no longer needs devMode: loopback and custom scheme redirects are both valid for a native app. - zitadel-init also updates an existing app's config, so the change reaches instances created before it (the client id is kept). - iOS takes the scheme from everything before the first ':', since the new URI has a single slash. --- deploy/compose.yml | 43 ++++++++++++------- mobile/README.md | 6 +-- .../src/androidMain/AndroidManifest.xml | 6 ++- .../kotlin/com/musify/app/Config.android.kt | 2 +- .../musify/app/auth/AuthCallbackActivity.kt | 2 +- .../kotlin/com/musify/app/Config.kt | 3 +- .../musify/app/auth/BrowserAuthLauncher.kt | 2 +- .../kotlin/com/musify/app/Config.ios.kt | 2 +- .../app/auth/BrowserAuthLauncher.ios.kt | 5 ++- 9 files changed, 43 insertions(+), 28 deletions(-) diff --git a/deploy/compose.yml b/deploy/compose.yml index fe08b30..cddfe5b 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -259,27 +259,38 @@ services: fi echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" + # Loopback (desktop) and a reverse-domain custom scheme (Android/iOS, + # RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so + # dev and prod share the same config. Unlike the other apps, an + # existing one is also updated, so a change here reaches instances + # created before it. NATIVE_APP_NAME="Musify Native" + NATIVE_CONFIG='{ + "redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"], + "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"], + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + "appType": "OIDC_APP_TYPE_NATIVE", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "devMode": false, + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "accessTokenRoleAssertion": true, + "idTokenRoleAssertion": true, + "idTokenUserinfoAssertion": true + }' NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME") if [ -n "$$NATIVE_APP_ID" ]; then NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId') - echo " reused (clientId $$NATIVE_CLIENT_ID)" + # Zitadel answers 400 "No changes" when the config already matches. + RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \ + -H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG") + case "$$RESULT" in + *'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;; + *COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;; + *) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;; + esac else - NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{ - "name": "Musify Native", - "redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"], - "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"], - "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], - "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], - "appType": "OIDC_APP_TYPE_NATIVE", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", - "version": "OIDC_VERSION_1_0", - "devMode": true, - "accessTokenType": "OIDC_TOKEN_TYPE_JWT", - "accessTokenRoleAssertion": true, - "idTokenRoleAssertion": true, - "idTokenUserinfoAssertion": true - }' | jq -r '.clientId') + NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId') echo " created (clientId $$NATIVE_CLIENT_ID)" fi diff --git a/mobile/README.md b/mobile/README.md index bb91d38..69d1217 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z Redirect URIs registrados: - Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo). -- Android / iOS: `musify://callback` (custom scheme). +- Android / iOS: `cat.ikerdemo.musify:/callback` (custom scheme con dominio invertido, RFC 8252 §7.1). `mobile/gradle.properties` controla a qué backend apunta Android/Desktop: @@ -125,13 +125,13 @@ Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`, 1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`). -2. Registra el scheme `musify` en `Info.plist`: +2. Registra el scheme `cat.ikerdemo.musify` en `Info.plist`: ```xml CFBundleURLTypes CFBundleURLSchemes - musify + cat.ikerdemo.musify ``` diff --git a/mobile/composeApp/src/androidMain/AndroidManifest.xml b/mobile/composeApp/src/androidMain/AndroidManifest.xml index 6a9094a..baafc80 100644 --- a/mobile/composeApp/src/androidMain/AndroidManifest.xml +++ b/mobile/composeApp/src/androidMain/AndroidManifest.xml @@ -18,7 +18,9 @@ - - + diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt index aaad041..5ed1152 100644 --- a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt @@ -9,5 +9,5 @@ actual object AppConfig { // Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and // the redirect URI registered for the "Musify Native" app in Zitadel. - actual val nativeRedirectUri: String = "musify://callback" + actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback" } diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt index 3fc4bc0..d1ace95 100644 --- a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Bundle /** - * Declared in AndroidManifest.xml with an intent-filter for `musify://callback`. + * Declared in AndroidManifest.xml with an intent-filter for `cat.ikerdemo.musify:/callback`. * Chrome Custom Tabs redirects here once Zitadel finishes the login/register * flow; this activity just forwards the URL to [AndroidAuthBridge] and closes. */ diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt index 33da1be..81f8bdb 100644 --- a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt @@ -9,7 +9,8 @@ package com.musify.app * `nativeRedirectUri` is the OAuth redirect registered for the "Musify * Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed * loopback port on Desktop (RFC 8252 native-app loopback flow), and the - * `musify://callback` custom scheme on Android/iOS. + * `cat.ikerdemo.musify:/callback` reverse-domain custom scheme on Android/iOS + * (RFC 8252 §7.1: based on a domain we own, so no other app plausibly claims it). */ expect object AppConfig { val apiBaseUrl: String diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt index 137a8c8..05947cc 100644 --- a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt @@ -12,7 +12,7 @@ package com.musify.app.auth * - 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. + * [AuthCallbackActivity] via the `cat.ikerdemo.musify:/callback` intent filter. * - iOS: `ASWebAuthenticationSession`, which owns the whole round trip. */ expect class BrowserAuthLauncher() { diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt index ebd3b63..c8272d1 100644 --- a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt @@ -12,5 +12,5 @@ actual object AppConfig { // Matches CFBundleURLTypes in Info.plist and the redirect URI registered // for the "Musify Native" app in Zitadel. - actual val nativeRedirectUri: String = "musify://callback" + actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback" } diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt index cbb1006..d2b136d 100644 --- a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt @@ -23,14 +23,15 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese /** * `ASWebAuthenticationSession` owns the whole round trip: it opens a system * browser sheet at [authorizeUrl] (Zitadel's hosted login, with its - * "Regístrate" link) and intercepts the `musify://` redirect itself, no app + * "Regístrate" link) and intercepts the `cat.ikerdemo.musify:` redirect itself, no app * delegate / `onOpenURL` wiring required. */ @OptIn(ExperimentalForeignApi::class) actual class BrowserAuthLauncher actual constructor() { actual suspend fun launch(authorizeUrl: String, redirectUri: String): String = suspendCancellableCoroutine { continuation -> - val scheme = redirectUri.substringBefore("://") + // `scheme:/path` (RFC 8252 private-use scheme): everything before the first ':'. + val scheme = redirectUri.substringBefore(":") val contextProvider = PresentationContextProvider() val session = ASWebAuthenticationSession(