feat(ios): capa social y de teléfono de iOS completa (pila 003→010) - #21
Merged
Conversation
… para Swift Apple era el único provider con entitlement en el demo y sin una sola línea de Swift: el botón solo registraba un warning y devolvía null. Se añade AppleSignInCoordinator, que es la implementación de referencia que el README manda copiar, y cierra los cuatro fallos que solo aparecen en runtime: - nonce con SecRandomCopyBytes comprobando el OSStatus, abortando en vez de caer a un valor predecible; si falta el nonce en el delegate se rechaza el token en lugar de firmar sin protección de replay; - referencia fuerte al ASAuthorizationController, que el sistema no retiene y sin la cual la hoja no llega a aparecer; - un único finish(), de modo que la completion de Kotlin se llama exactamente una vez en todos los caminos, cancelación incluida — si no, la corrutina no reanuda y el botón se queda girando para siempre; - el fullName, que Apple manda solo en la PRIMERA autorización de cada usuario, viaja en el segmento |||displayName||| que applyPendingDisplayName persiste. El KDoc de AppleSignInProviderIOS es el contrato de integración y enseñaba Swift que no compila: `.companion` sobre un `object` no existe, solo lo tiene el companion de una clase (GoogleSignInProviderIOS). El mismo error estaba en los otros cinco providers, así que se corrige en todos. Logger.ios.kt pasaba el mensaje ya interpolado como format string de NSLog. Está justo en la ruta de error de Apple, y un `%` en el mensaje —las URLs percent-encoded de OAuth los llevan— lee memoria arbitraria de los varargs. Sin cambios en commonMain: la API pública y los cinco tests de FLE-90 sobre el formato de token de Apple quedan intactos. Verificado ktlintCheck y testDebugUnitTest; el lado Swift no es compilable desde Windows y queda pendiente de xcodebuild en Mac. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
… el demo MainViewController ya habilitaba los cinco providers, así que la pantalla pintaba sus botones, pero en Swift no había ningún handler: cada pulsación registraba un warning, devolvía null y el usuario veía "cancelled or failed". Con Apple ya resuelto, quedaban estos. FirebaseOAuthCoordinator cubre los cuatro que Firebase resuelve por su flujo OAuth web, igual que WebOAuthProviderAndroid en la otra plataforma. Aquí es Swift quien completa el login entero y la librería solo necesita saber que ocurrió: eso es el centinela PLATFORM_AUTH_HANDLED, que se lee de cada objeto en vez de repetir la cadena mágica. Los scopes replican los de LoginLibraryConfig para que ambas plataformas pidan lo mismo. El OAuthProvider se guarda en un diccionario por provider id porque el SDK no lo retiene mientras dura el flujo: en una variable local se libera antes del callback y el navegador abre para no volver nunca. PhoneAuthCoordinator cubre los dos saltos del OTP. La parte que faltaba en ambos y que no está en el código de login: el callback de vuelta. Con el ciclo de vida de SwiftUI, application(_:open:options:) no se llama —las URLs llegan a la escena—, así que el flujo web y el fallback reCAPTCHA del teléfono no volvían nunca. Se añade .onOpenURL sobre el WindowGroup y un AppDelegate.handle(_:) que da prioridad a Auth.auth().canHandle(url) antes que a GoogleSignIn. Se implementan también setAPNSToken y canHandleNotification, que son lo que permite a Firebase verificar la app con un push silencioso en vez de mandar al usuario a una página de reCAPTCHA. Solo cambia el demo y el README: la librería no se toca. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
…de iOS Se implementó como fix directo, fuera del harness. El spec se escribe después para que el trabajo quede documentado en specs/ como el resto: alcance, quién firma en cada provider, los tres detalles que deciden si el flujo funciona y una tabla de trazabilidad honesta —ningún AC es unitario, todo el código nuevo es Swift y el repo no tiene target de test de iOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
La guía 5.1.1(v) de App Review no se conforma con eliminar el usuario de Firebase: para Sign in with Apple exige revocar el token, o la app sigue apareciendo en Ajustes → Cuenta de Apple y la revisión se rechaza. Ningún consumidor podía pasar revisión sin escribirse esto por su cuenta, que es justo lo que la librería existe para evitar. Entra como tercer puerto de la familia de FLE-90 —SocialTokenRevoker, junto a SocialTokenProvider y SocialSignInStateCleaner— por el mismo motivo que los otros dos: debajo hay una función expect de nivel superior, y esas no se pueden falsear desde commonTest. Con default en el constructor, así que quien construya FirebaseAuthProvider con tres argumentos sigue compilando; los tests de FLE-90 no se tocan y son la prueba. A quién revocar lo decide el usuario, no la configuración: FirebaseAuthUser gana providerIds y el adaptador de GitLive lo rellena desde providerData. Un usuario de email en una app con Apple habilitado no debe ver ninguna hoja de Apple al borrar su cuenta. Best effort a propósito: si la revocación falla, la cuenta se borra igual. Quien pide borrar su cuenta no puede quedarse atrapado porque un servidor no conteste, y el resultado contrario —cuenta viva, token revocado— es peor para él. Opt-in en iOS: sin revokeHandler asignado se registra un warning y no pasa nada, para que un host que ya tiene en producción su flujo de borrado no se encuentre de golpe una hoja de Apple al actualizar el pin. El handler del demo vuelve a lanzar la autorización de Apple en vez de reutilizar el código del login: revokeToken exige un authorizationCode fresco y de un solo uso, y de paso satisface el recent login que Firebase pide para borrar. testOptions.unitTests.isReturnDefaultValues: android.util.Log es un stub que lanza "not mocked" en tests de host, así que cualquier test que recorra un catch fallaba por el log y no por el comportamiento. Salió al escribir AC-03. Rojo antes de implementar en AC-01, AC-03 y AC-05; AC-02 y AC-04 nacieron verdes y se documentan como guardias de regresión en la tabla de trazabilidad, en vez de apuntarlos como TDD que no fue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
clearSocialSignInState existía desde FLE-90 porque cerrar sesión en Firebase no basta, y en iOS se dejó como no-op con un comentario que decía que ni ASAuthorizationController ni GIDSignIn cachean nada. De Apple es cierto; de Google no: GIDSignIn.sharedInstance.currentUser vive en el llavero y sobrevive al signOut de Firebase, así que en iOS el siguiente login reutilizaba la cuenta anterior y el usuario no tenía forma de cambiar de cuenta desde la app. Es la misma avería que el ticket original arregló en Android, en la plataforma que se dio por buena sin comprobarla. Se resuelve con un seam, no con una llamada directa, porque :custom-login no depende del SDK de GoogleSignIn ni debe hacerlo: el host lo trae por SPM. Sin handler asignado el comportamiento es el de hoy —un warning y nada más—, y un handler que lance se registra y se traga, porque la sesión de Firebase ya está cerrada a esas alturas y fallar aquí solo convertiría un cierre correcto en un error visible. No añade tests, y el spec lo dice explícitamente: todo lo que cambia vive por debajo de SocialSignInStateCleaner, que es justo donde commonTest deja de alcanzar. Comprobar otra vez que signOut llama al puerto sería duplicar el test de FLE-90 y fingir cobertura nueva. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
AppleSignInConfig.scopes es configuración pública y en Android se lee de verdad para el flujo OAuth web. En iOS no la leía nadie: el handler recibía un String? documentado como "reservado para futura config" y siempre valía null, así que los scopes estaban a fuego en el host. Un campo que funciona en una plataforma y en la otra no es peor que no tenerlo, porque quien lo cambie creerá que aplica a las dos. Se llena el hueco que el seam ya tenía reservado, así que es aditivo: un host integrado hoy lo ignora con "_" y sigue pidiendo lo mismo, que además es el valor por defecto configurado. Viaja como cadena separada por comas y no como lista porque una List<String> cruza a Swift como [Any] y obligaría al host a castear. El fallback es "los dos scopes", nunca "ninguno": pedir cero scopes hace que Apple no devuelva el nombre, y el nombre solo llega en la primerísima autorización de cada usuario. Un error de configuración no puede costar eso. signIn() gana el parámetro con default, así que ninguna llamada existente se rompe. Sin tests nuevos, y el spec explica por qué: el camino entero vive en el actual de iOS y en Swift, al otro lado de la frontera de commonTest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
El bundle id venía de la plantilla de KMP como com.apptolast.login.Login$(TEAM_ID). Para un ejemplo de usar y tirar está bien; aquí no, porque esa cadena tiene que coincidir a la vez con la app de iOS registrada en Firebase, con el cliente OAuth que emitió el reversed client id del Info.plist y con MagicLinkConfig.iosBundleId. Con el sufijo, rellenar TEAM_ID en local cambiaba la identidad de la app y rompía las tres en silencio. Y aun sin rellenarlo ya había discrepancia: el bundle era com.apptolast.login.Login y el magic link decía com.apptolast.login, así que el enlace no reabría la app. Se conserva com.apptolast.login.Login, no el otro candidato, porque es el que está en el árbol hoy con TEAM_ID vacío y por tanto el único que sabemos que casa con lo registrado en la consola. El GoogleService-Info.plist está en .gitignore y no se puede comprobar desde aquí; cambiar la identidad de la app por una corazonada rompería Google Sign-In sin avisar. Si al validar en Mac resulta que Firebase tiene com.apptolast.login, el arreglo es una línea en cada uno de los dos sitios y está escrito en el spec. Sigue faltando, y queda fuera de este ticket, montar los Universal Links de apptolast.com: sin eso el magic link no vuelve a la app aunque el bundle id ya sea correcto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
…ormas Entrar por SMS dejaba una sesión distinta según la plataforma: Android la construye leyendo el FirebaseUser, iOS solo recibe el uid de Swift y fabricaba UserSession(userId, email = null). Cualquier pantalla que pinte el email o mire isEmailVerified se comportaba distinto para el mismo usuario, y la que se equivocaba era iOS. El arreglo va en el sitio común, no en cada actual: cuando la plataforma dice que ha firmado, el usuario ya está en Firebase, así que la fuente de verdad es el gateway. Es el mismo patrón que ya usa SocialTokenResult.PlatformHandled, donde Swift firma y Kotlin vuelve a leer la sesión. Si el gateway no ve usuario se conserva el resultado de la plataforma, que es mejor que inventar un fallo cuando la plataforma acaba de decir que fue bien. Debajo había un agujero mayor: los dos puntos de entrada del teléfono son funciones expect de nivel superior, así que el flujo entero era intesteable desde commonTest — el mismo hueco que FLE-90 cerró para el resto del provider y dejó abierto aquí. Entra PhoneAuthPort, cuarto puerto de la misma familia, con default en el constructor. Rojo antes de implementar solo AC-01, que es el único que cambia comportamiento; los otros tres tests son cobertura que antes no se podía escribir y así se apuntan en la trazabilidad. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
… código muerto Cuatro restos de la auditoría, ninguno urgente y todos de los que cuestan una tarde a quien llega nuevo. El demo no se puede construir desde un clon limpio: google-services.json y GoogleService-Info.plist están en .gitignore y no están en el árbol. El primero rompe assembleDebug con un error claro; el segundo deja compilar y la app casca al arrancar en FirebaseApp.configure(). Ahora CLAUDE.md dice qué dos ficheros hacen falta, dónde van y qué pasa si falta cada uno. Salió al ejecutar assembleDebug en el spec 008: hasta entonces solo se habían corrido tareas de :custom-login, que no necesitan ninguno. onSignInResult no lo llama nadie —el resultado viaja en el completion del signInHandler— y getTopViewController usa UIApplication.windows, deprecado en iOS 15 e indiferente a qué escena está en primer plano. Se deprecan con WARNING en vez de borrarse: es API pública de una librería que los consumidores pinean por SHA, y borrar obliga a cambiar a todo el mundo. El handler de Google del demo cogía connectedScenes.first, que puede ser una escena en segundo plano, y registraba con print. Los tres coordinadores de los specs 003-005 hacen ambas cosas bien, así que desentonaba justo en el fichero que un consumidor abre primero. El pbxproj NO se toca, y el spec explica por qué: desde FLE-91 el enlazado de ComposeApp.framework es implícito, y la ruta correcta depende de dónde deje el framework embedAndSignAppleFrameworkForXcode. Eso no se puede comprobar en Windows, y escribir una ruta plausible en un fichero de build que no puedo compilar es peor que dejarlo, porque parece un arreglo. Quedan los dos comandos que lo resuelven en el Mac. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
hgarciaalberto
changed the base branch from
feature/009-phone-session-parity
to
develop
August 19, 2026 22:20
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Qué entra
ce7489fc35226a3dd97441d5e107cb7a3220512a46AppleSignInConfigal handler de iOS6fc5fae6e4e4cbfacf5c2Por qué de golpe
Las PRs
#15–#21apuntaban cada una a la rama de la anterior, y el CI solo disparaba en PRscontra
develop/main— así que ninguna de las ocho había pasado un solo check. Eso loarregla #23, ya mergeado. Al reapuntar esta PR a
develop, el CI valida por fin la pila enterade una sola pasada.
Merge en squash:
developse queda con un commit por PR, como marca la convención del repo.Contexto
Detrás de esto viene el renombrado
custom-login→baselogin(módulo, paquete Kotlin,namespace y recursos), que toca 142 ficheros y saldrá como 2.0.0. Se ha decidido meterlo
después de la pila precisamente para no obligar a rebasar nueve ramas a través de un rename
de directorio.
🤖 Generated with Claude Code