Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions iosApp/iosApp/Pubky/IosPubkyClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,31 @@ interface PubkyClient {
suspend fun signOut(sessionSecret: String): Result<String>
suspend fun revalidateSession(sessionSecret: String): Result<String>

/** 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<String>
suspend fun awaitAuthApproval(): Result<String>
fun parseAuthUrl(url: String): Result<String>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IllegalStateException> { parseSessionPayload(payload, json) }

assertTrue("session_secret" in error.message.orEmpty())
}
}
Loading