diff --git a/iosApp/iosApp/Pubky/IosPubkyClient.swift b/iosApp/iosApp/Pubky/IosPubkyClient.swift index 9adb97f7..6c016d12 100644 --- a/iosApp/iosApp/Pubky/IosPubkyClient.swift +++ b/iosApp/iosApp/Pubky/IosPubkyClient.swift @@ -2,7 +2,10 @@ import Foundation import Shared /// Which application the homeserver is being told it is talking to, required by pubky 0.10's -/// `ClientId` on every sign-in, sign-up, auth flow and secret-key write. +/// `ClientId` on every sign-in, sign-up and secret-key write. +/// +/// Not on the Ring deeplink flow: that binds to the cookie variant, which predates `ClientId` +/// and takes none — see `PubkyClient.startAuthFlow` for why. /// /// The type wants a domain string (its own example is `franky.pubky.app`), non-empty and at most /// 253 characters — not the iOS bundle identifier. Kept byte-for-byte identical to @@ -75,10 +78,10 @@ final class IosPubkyClient: NSObject, RawPubkyClient { } func startAuthFlow(capabilities: String) -> [String] { - Loopky.startAuthFlow(capabilitiesStr: capabilities, clientId: loopkyClientId) + Loopky.startCookieAuthFlow(capabilitiesStr: capabilities) } - func awaitAuthApproval() -> [String] { Loopky.awaitAuthApproval() } + func awaitAuthApproval() -> [String] { Loopky.awaitCookieAuthApproval() } func parseAuthUrl(url: String) -> [String] { Loopky.parseAuthUrl(url: url) } diff --git a/shared/src/androidMain/kotlin/com/github/jvsena42/loopky/data/pubky/AndroidPubkyClient.kt b/shared/src/androidMain/kotlin/com/github/jvsena42/loopky/data/pubky/AndroidPubkyClient.kt index c154002d..387f3e0c 100644 --- a/shared/src/androidMain/kotlin/com/github/jvsena42/loopky/data/pubky/AndroidPubkyClient.kt +++ b/shared/src/androidMain/kotlin/com/github/jvsena42/loopky/data/pubky/AndroidPubkyClient.kt @@ -3,7 +3,7 @@ package com.github.jvsena42.loopky.data.pubky import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import uniffi.pubkycore.auth as ffiAuth -import uniffi.pubkycore.awaitAuthApproval as ffiAwaitAuthApproval +import uniffi.pubkycore.awaitCookieAuthApproval as ffiAwaitCookieAuthApproval import uniffi.pubkycore.createRecoveryFile as ffiCreateRecoveryFile import uniffi.pubkycore.createTagId as ffiCreateTagId import uniffi.pubkycore.decryptRecoveryFile as ffiDecryptRecoveryFile @@ -33,13 +33,16 @@ import uniffi.pubkycore.revalidateSession as ffiRevalidateSession import uniffi.pubkycore.signIn as ffiSignIn import uniffi.pubkycore.signOut as ffiSignOut import uniffi.pubkycore.signUp as ffiSignUp -import uniffi.pubkycore.startAuthFlow as ffiStartAuthFlow +import uniffi.pubkycore.startCookieAuthFlow as ffiStartCookieAuthFlow import uniffi.pubkycore.switchNetwork as ffiSwitchNetwork import uniffi.pubkycore.validateMnemonicPhrase as ffiValidateMnemonicPhrase /** * Which application the homeserver is being told it is talking to, required by pubky 0.10's - * `ClientId` on every sign-in, sign-up, auth flow and secret-key write. + * `ClientId` on every sign-in, sign-up and secret-key write. + * + * Not on the Ring deeplink flow: that binds to the cookie variant, which predates `ClientId` and + * takes none — see [PubkyClient.startAuthFlow] for why. * * The type wants a domain string (its own example is `franky.pubky.app`), non-empty and at most * 253 characters — not the Android application id, which is what makes this a constant here @@ -103,9 +106,9 @@ class AndroidPubkyClient : PubkyClient { runFfiSuspend { ffiRevalidateSession(sessionSecret) } override suspend fun startAuthFlow(capabilities: String) = - runFfiSuspend { ffiStartAuthFlow(capabilities, LOOPKY_CLIENT_ID) } + runFfiSuspend { ffiStartCookieAuthFlow(capabilities) } - override suspend fun awaitAuthApproval() = runFfiSuspend { ffiAwaitAuthApproval() } + override suspend fun awaitAuthApproval() = runFfiSuspend { ffiAwaitCookieAuthApproval() } override fun parseAuthUrl(url: String) = runFfi { ffiParseAuthUrl(url) } override suspend fun auth(url: String, secretKey: String) = runFfiSuspend { ffiAuth(url, secretKey) } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/PubkyClient.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/PubkyClient.kt index 8bd7c52e..75e73d47 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/PubkyClient.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/PubkyClient.kt @@ -38,7 +38,31 @@ interface PubkyClient { suspend fun signOut(sessionSecret: String): Result suspend fun revalidateSession(sessionSecret: String): Result - /** Pubky Ring-style deeplink flow. */ + /** + * Pubky Ring-style deeplink flow. + * + * Both bind to the FFI's **cookie** variant (`start_cookie_auth_flow` / + * `await_cookie_auth_approval`), not the grant variant its plain `start_auth_flow` now + * delegates to. That is a compatibility choice about the app on the other end of the + * deeplink, not a preference: + * + * - pubky 0.10's grant flow mints `pubkyauth://signin_grant?…&cid=…&cpk=…`. Every released + * Pubky Ring bundles `react-native-pubky@0.13.0` — pubky 0.9.x, whose deeplink parser knows + * `signin`, `signup`, `direct_signup` and `session` and nothing else. It answers a grant URL + * with "Unrecognized format" and the user cannot sign in at all. + * - The grant flow also returns its session secret as `grant_secret`, where the rest of this + * client — [signOut], [revalidateSession], `put_with_session` — expects the `session_secret` + * the cookie flow returns. (`restore_session` takes either, so that half is survivable; + * the deeplink half is not.) + * + * The cookie flow emits `pubkyauth://signin?caps=…&relay=…&secret=…`, which is what Loopky + * sent before the 0.10 bindings bump and what Ring understands today. It carries no + * `ClientId`, which is why these two are the only calls here that do not pass one. + * + * Revisit when Ring ships a release built on pubky 0.10 — the work is started on its + * `chore/pubky-0.10.0` branch, blocked on `react-native-pubky` publishing a 0.10 build. + * Upstream marks the cookie flow deprecated, so this is a hold, not a destination. + */ suspend fun startAuthFlow(capabilities: String): Result suspend fun awaitAuthApproval(): Result fun parseAuthUrl(url: String): Result diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParser.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParser.kt index 7ab7e2c7..fa7fd707 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParser.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParser.kt @@ -13,19 +13,24 @@ import kotlinx.serialization.json.jsonPrimitive /** * Parses the session JSON payload returned by `pubky-core-ffi-fork` into a [Session]. * - * Payload shape (from `utils::session_to_json_with_secret`): + * Payload shape (from `utils::session_to_json_with_cookie_secret`): * ```json * { "pubky": "...", "capabilities": ["/pub/loopky/:rw"], "session_secret": "..." } * ``` * - * Extra/aliased field names are tolerated so a future FFI bump continues to work. + * Extra/aliased field names are tolerated so a future FFI bump continues to work. `grant_secret` + * is one such alias and not a hypothetical: pubky 0.10's grant flow names the field that instead, + * and the two are interchangeable downstream because the FFI's `restore_session` sniffs which kind + * of token it was handed. Loopky asks for the cookie flow today (see + * [PubkyClient.startAuthFlow]), so the alias is what keeps a switch back to grant auth from + * failing here with a missing-field error rather than anywhere informative. */ internal fun parseSessionPayload(payload: String, json: Json): Session { val obj: JsonObject = json.parseToJsonElement(payload).jsonObject val pubkey = obj.stringField("pubky", "public_key", "publicKey") ?: error("session payload missing 'pubky'") - val secret = obj.stringField("session_secret", "sessionSecret", "secret") + val secret = obj.stringField("session_secret", "sessionSecret", "grant_secret", "grantSecret", "secret") ?: error("session payload missing 'session_secret'") val homeserver = obj.stringField("homeserver", "home_server").orEmpty() val caps = obj["capabilities"] diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParserTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParserTest.kt new file mode 100644 index 00000000..a36a58f9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/loopky/data/pubky/SessionPayloadParserTest.kt @@ -0,0 +1,49 @@ +package com.github.jvsena42.loopky.data.pubky + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The FFI names the session secret differently per auth flow — `session_secret` from the cookie + * flow Loopky uses, `grant_secret` from the grant flow pubky 0.10 added. Both parse, because the + * value is interchangeable downstream and a rename here surfaces as a missing-field error miles + * from its cause. + */ +class SessionPayloadParserTest { + + private val json = Json { ignoreUnknownKeys = true } + private val secret = "U55XnoH6vsMCpx1pxHtt8fReVg4Brvu9C0gUBuw-Jkw" + private val pubky = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + + @Test + fun theCookieFlowPayloadParses() { + val payload = """{"pubky":"$pubky","capabilities":["/pub/loopky/:rw"],"session_secret":"$secret"}""" + + val session = parseSessionPayload(payload, json) + + assertEquals(pubky, session.identity.pubky) + assertEquals(secret, session.sessionSecret) + assertEquals(listOf("/pub/loopky/:rw"), session.capabilities.map { it.value }) + } + + @Test + fun theGrantFlowPayloadParsesToTheSameSession() { + val payload = """{"pubky":"$pubky","capabilities":["/pub/loopky/:rw"],"grant_secret":"$secret"}""" + + val session = parseSessionPayload(payload, json) + + assertEquals(secret, session.sessionSecret, "grant_secret is the same secret under another name") + } + + @Test + fun aPayloadWithNoSecretAtAllFails() { + val payload = """{"pubky":"$pubky","capabilities":[]}""" + + val error = assertFailsWith { parseSessionPayload(payload, json) } + + assertTrue("session_secret" in error.message.orEmpty()) + } +}