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.
This commit is contained in:
+23
-12
@@ -259,27 +259,38 @@ services:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
|
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_APP_NAME="Musify Native"
|
||||||
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
|
NATIVE_CONFIG='{
|
||||||
if [ -n "$$NATIVE_APP_ID" ]; then
|
"redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"],
|
||||||
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
|
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"],
|
||||||
echo " reused (clientId $$NATIVE_CLIENT_ID)"
|
|
||||||
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"],
|
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
|
||||||
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
||||||
"appType": "OIDC_APP_TYPE_NATIVE",
|
"appType": "OIDC_APP_TYPE_NATIVE",
|
||||||
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
|
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
|
||||||
"version": "OIDC_VERSION_1_0",
|
"devMode": false,
|
||||||
"devMode": true,
|
|
||||||
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
|
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
|
||||||
"accessTokenRoleAssertion": true,
|
"accessTokenRoleAssertion": true,
|
||||||
"idTokenRoleAssertion": true,
|
"idTokenRoleAssertion": true,
|
||||||
"idTokenUserinfoAssertion": true
|
"idTokenUserinfoAssertion": true
|
||||||
}' | jq -r '.clientId')
|
}'
|
||||||
|
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')
|
||||||
|
# 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" "$$(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)"
|
echo " created (clientId $$NATIVE_CLIENT_ID)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z
|
|||||||
|
|
||||||
Redirect URIs registrados:
|
Redirect URIs registrados:
|
||||||
- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo).
|
- 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:
|
`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
|
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`).
|
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
|
```xml
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array><string>musify</string></array>
|
<array><string>cat.ikerdemo.musify</string></array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register.
|
<!-- Catches the `cat.ikerdemo.musify:/callback` redirect Zitadel sends after
|
||||||
|
login/register. The URI has no host, so the filter matches on the scheme
|
||||||
|
alone.
|
||||||
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
|
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".auth.AuthCallbackActivity"
|
android:name=".auth.AuthCallbackActivity"
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="musify" android:host="callback" />
|
<data android:scheme="cat.ikerdemo.musify" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ actual object AppConfig {
|
|||||||
|
|
||||||
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
|
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
|
||||||
// the redirect URI registered for the "Musify Native" app in Zitadel.
|
// 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"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
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
|
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register
|
||||||
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
|
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ package com.musify.app
|
|||||||
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify
|
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify
|
||||||
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
|
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
|
||||||
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the
|
* 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 {
|
expect object AppConfig {
|
||||||
val apiBaseUrl: String
|
val apiBaseUrl: String
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ package com.musify.app.auth
|
|||||||
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
|
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
|
||||||
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
|
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
|
||||||
* - Android: Chrome Custom Tabs; the redirect is caught by
|
* - 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.
|
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
|
||||||
*/
|
*/
|
||||||
expect class BrowserAuthLauncher() {
|
expect class BrowserAuthLauncher() {
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ actual object AppConfig {
|
|||||||
|
|
||||||
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered
|
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered
|
||||||
// for the "Musify Native" app in Zitadel.
|
// for the "Musify Native" app in Zitadel.
|
||||||
actual val nativeRedirectUri: String = "musify://callback"
|
actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese
|
|||||||
/**
|
/**
|
||||||
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system
|
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system
|
||||||
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
|
* 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.
|
* delegate / `onOpenURL` wiring required.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual class BrowserAuthLauncher actual constructor() {
|
actual class BrowserAuthLauncher actual constructor() {
|
||||||
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
|
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
|
||||||
suspendCancellableCoroutine { continuation ->
|
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 contextProvider = PresentationContextProvider()
|
||||||
|
|
||||||
val session = ASWebAuthenticationSession(
|
val session = ASWebAuthenticationSession(
|
||||||
|
|||||||
Reference in New Issue
Block a user