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
21 changes: 21 additions & 0 deletions LAWS.bend
Original file line number Diff line number Diff line change
Expand Up @@ -2935,6 +2935,27 @@ law google_hop_tailnet_only:
law google_hop_no_path:
{GO.Google.hop("state=" ++ GO.Google.state("n0", "https://evil.example.com/x.ts.net") ++ "&code=c", "") == "" : String}

# a phone's own loopback redirect begins a sign-in (RFC 8252 7.3)
law google_loopback_phone:
{GO.Google.redirect.pick("http://127.0.0.1:49152/oauth/google", "http://127.0.0.1:3787/oauth/google") == "http://127.0.0.1:49152/oauth/google" : String}

# none asked: the hub's own
law google_loopback_none:
{GO.Google.redirect.pick("", "http://127.0.0.1:3787/oauth/google") == "http://127.0.0.1:3787/oauth/google" : String}

# only loopback: Google's code never goes to another host
law google_loopback_not_host:
{GO.Google.loopback("http://evil.example.com:80/oauth/google") == False{} : Bool}

law google_loopback_not_userinfo:
{GO.Google.loopback("http://127.0.0.1:1@evil.example.com/oauth/google") == False{} : Bool}

law google_loopback_not_path:
{GO.Google.loopback("http://127.0.0.1:80/x/oauth/google") == False{} : Bool}

law google_loopback_no_port:
{GO.Google.loopback("http://127.0.0.1:/oauth/google") == False{} : Bool}

# a To, Cc or Subject cannot start a header of its own
law google_header_no_newline_example:
{GO.Mail.clean("a@x.org\r\nBcc: e@x.org") == "a@x.org Bcc: e@x.org" : String}
Expand Down
18 changes: 18 additions & 0 deletions PROOF.bend
Original file line number Diff line number Diff line change
Expand Up @@ -5083,6 +5083,24 @@ def Laws.google_hop_tailnet_only():
def Laws.google_hop_no_path():
{==}

def Laws.google_loopback_phone():
{==}

def Laws.google_loopback_none():
{==}

def Laws.google_loopback_not_host():
{==}

def Laws.google_loopback_not_userinfo():
{==}

def Laws.google_loopback_not_path():
{==}

def Laws.google_loopback_no_port():
{==}

def Laws.google_own_client_wins(mine, sh, h):
%Equal.sym(Bool, GO.Client.has(mine), True{}, h) : {GO.Google.client.k(_, mine, sh) == mine : GO.Client}
{==}
Expand Down
15 changes: 15 additions & 0 deletions docs/bots.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,21 @@ redirect there (`Google.hop`: only to a `*.ts.net` address, only once;
laws `google_state_*`, `google_hop_*`). With no hub on the browser's
machine, paste the address the browser ended on instead.

The phone apps sign in with no hub on the phone (RFC 8252 7.3): Connect
Google (or Add account) opens a listener on a loopback port of the app's
own (`GoogleLoop` in `GoogleLoop.swift` and `GoogleLoop.kt`) and sends
the `google` action `connect@<port>`, so the hub begins the sign-in with
`http://127.0.0.1:<port>/oauth/google` as its redirect (the RPC's
`redirect`; the hub takes only exactly that shape, `Google.loopback`,
else its own: laws `google_loopback_*`). The app opens the link the hub
answers when its `redirect_uri` is the listener's (iOS in an
`ASWebAuthenticationSession`, Android in the browser), the listener
catches Google's redirect, sends the browser back to the app
(`backplane://google`), and the app sends `done:<address>`: the hub's
`finish` with that address, traded with the redirect the sign-in began
with. It finishes on the hub in focus; the pasted address stays as the
fallback.

Several accounts: each sign-in adds one (Google shows its account
chooser; signing in to an address already there replaces it). They live
in `<home>/secrets/google.json` under `accounts`, the default first (a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ private fun SettingsTab(m: AppModel, b: BotView, st: BotSettings) {
OutlinedButton(onClick = { m.act("bot-edit") }, enabled = persona != st.persona, shape = square) { Text("Save") }

SectionLabel("Google")
val ctx = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(st.google.url) { GoogleLoop.open(ctx, st.google.url) }
if (st.google.status.isNotEmpty()) Text(st.google.status, style = MaterialTheme.typography.bodyMedium)
st.google.accounts.forEach { a ->
Row(verticalAlignment = Alignment.CenterVertically) {
Expand All @@ -506,7 +508,7 @@ private fun SettingsTab(m: AppModel, b: BotView, st: BotSettings) {
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { m.act("google", "connect") }, shape = square) { Text(if (st.google.accounts.isEmpty()) "Connect Google" else "Add account") }
OutlinedButton(onClick = { GoogleLoop.start { a, v -> m.act(a, v) } }, shape = square) { Text(if (st.google.accounts.isEmpty()) "Connect Google" else "Add account") }
TextButton(onClick = { gown = !gown }) { Text(if (gown) "Hide own client" else "Own client") }
}
if (gown) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dev.backplane.mobile

import android.content.Context
import android.content.Intent
import android.net.Uri
import java.net.InetAddress
import java.net.ServerSocket
import kotlin.concurrent.thread

// Google's sign-in from the phone (RFC 8252 7.3, docs/bots.md "Google"):
// a listener on a loopback port of the app's own, the hub asked to begin
// with that redirect ("connect@<port>"), Google's page in the browser, and
// the address Google sent the browser back to handed to the hub
// ("done:<address>"), which trades the code. The page it answers sends the
// browser back to the app (backplane://google). The hub keeps the
// accounts; each sign-in adds one.
object GoogleLoop {
private var server: ServerSocket? = null
private var port = 0
private var opened = false

@Synchronized
fun start(act: (String, String) -> Unit) {
stop()
val s = runCatching { ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) }.getOrNull()
if (s == null) { act("google", "connect"); return }
server = s
port = s.localPort
val p = port
thread(isDaemon = true, name = "google-loop") {
while (true) {
val c = runCatching { s.accept() }.getOrNull() ?: break
val path = runCatching {
c.soTimeout = 10000
val line = c.getInputStream().bufferedReader().readLine() ?: ""
line.split(" ").getOrNull(1) ?: ""
}.getOrDefault("")
if (!path.startsWith("/oauth/google?")) {
runCatching { c.getOutputStream().write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray()); c.close() }
continue
}
val page = "<!doctype html><meta name=viewport content=\"width=device-width\"><title>Backplane</title>" +
"<p style=\"font:16px sans-serif;margin:24px\">Signed in. <a href=\"backplane://google\">Back to Backplane</a></p>" +
"<script>location.href='backplane://google'</script>"
val body = page.toByteArray()
runCatching {
c.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${body.size}\r\n" +
"Cache-Control: no-store\r\nConnection: close\r\n\r\n").toByteArray() + body)
c.close()
}
act("google", "done:http://127.0.0.1:$p$path")
stopIf(s)
break
}
}
act("google", "connect@$p")
}

// the hub's sign-in link: opened once, when it sends Google back here
@Synchronized
fun open(ctx: Context, link: String) {
if (server == null || opened || port == 0 || link.isEmpty()) return
val u = Uri.parse(link)
if (u.getQueryParameter("redirect_uri") != "http://127.0.0.1:$port/oauth/google") return
opened = true
runCatching { ctx.startActivity(Intent(Intent.ACTION_VIEW, u).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}

@Synchronized
private fun stopIf(s: ServerSocket) {
if (server === s) stop()
}

@Synchronized
fun stop() {
runCatching { server?.close() }
server = null
port = 0
opened = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class MainActivity : ComponentActivity() {
// backplane://pair?url=... pairs with that hub too
private fun pairFrom(intent: Intent?) {
val data = intent?.data ?: return
if (data.scheme == "backplane") model.pair(data.toString())
// (backplane://google ends a Google sign-in: GoogleLoop)
if (data.scheme == "backplane" && data.host != "google") model.pair(data.toString())
}
}

Expand Down
3 changes: 2 additions & 1 deletion mobile/ios/Backplane/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,8 @@ final class AppModel {
if !id.isEmpty { act("select", id) }
// the island with several threads: pick one (backplane://open?pick=1)
else if q.contains(where: { $0.name == "pick" }), (screen?.island.lines.count ?? 0) > 1 { picking = true }
} else {
} else if url.host != "google" {
// (backplane://google ends a Google sign-in: GoogleLoop)
pair(url.absoluteString)
}
}
Expand Down
3 changes: 2 additions & 1 deletion mobile/ios/Backplane/Bots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ private struct SettingsTab: View {
.buttonStyle(.borderless)
}
}
Button(settings.google.accounts.isEmpty ? "Connect Google" : "Add account") { model.act("google", "connect") }
Button(settings.google.accounts.isEmpty ? "Connect Google" : "Add account") { GoogleLoop.shared.start { model.act($0, $1) } }
DisclosureGroup("Own client") {
TextField("Client ID (Desktop app)", text: $gid)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Expand Down Expand Up @@ -521,6 +521,7 @@ private struct SettingsTab: View {
Button("Delete bot", role: .destructive) { deleting = true }
}
}
.onChange(of: settings.google.url) { _, u in GoogleLoop.shared.open(u) }
.onAppear {
persona = settings.personaField.isEmpty ? settings.persona : settings.personaField
gid = settings.google.gid
Expand Down
95 changes: 95 additions & 0 deletions mobile/ios/Backplane/GoogleLoop.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import AuthenticationServices
import Network
import UIKit

// Google's sign-in from the phone (RFC 8252 7.3, docs/bots.md "Google"):
// a listener on a loopback port of the app's own, the hub asked to begin
// with that redirect ("connect@<port>"), Google's page in an
// authentication session, and the address Google sent the browser back to
// handed to the hub ("done:<address>"), which trades the code. The hub
// keeps the accounts; each sign-in adds one.
@MainActor
final class GoogleLoop: NSObject, ASWebAuthenticationPresentationContextProviding {
static let shared = GoogleLoop()
private var listener: NWListener?
private var session: ASWebAuthenticationSession?
private var port: UInt16 = 0
private var opened = false
private var act: ((String, String) -> Void)?

// a listener up, then the hub asked for a sign-in to it
func start(_ act: @escaping (String, String) -> Void) {
stop()
self.act = act
let p = NWParameters.tcp
p.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
guard let l = try? NWListener(using: p) else { act("google", "connect"); return }
listener = l
l.newConnectionHandler = { [weak self] c in Task { @MainActor in self?.serve(c) } }
l.stateUpdateHandler = { [weak self] st in
Task { @MainActor in
guard let self, self.listener === l else { return }
switch st {
case .ready:
self.port = l.port?.rawValue ?? 0
act("google", "connect@\(self.port)")
case .failed:
self.stop()
act("google", "connect")
default: break
}
}
}
l.start(queue: .main)
}

// the hub's sign-in link: opened once, when it sends Google back here
func open(_ link: String) {
guard listener != nil, !opened, port != 0, let u = URL(string: link),
let r = URLComponents(url: u, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "redirect_uri" })?.value,
r == "http://127.0.0.1:\(port)/oauth/google" else { return }
opened = true
let s = ASWebAuthenticationSession(url: u, callbackURLScheme: "backplane") { [weak self] _, _ in
Task { @MainActor in self?.stop() }
}
s.presentationContextProvider = self
session = s
if !s.start() { stop() }
}

private func serve(_ c: NWConnection) {
c.start(queue: .main)
c.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, _, _ in
Task { @MainActor in
guard let self else { return }
let head = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let parts = head.split(separator: "\r\n").first?.split(separator: " ") ?? []
let path = parts.count > 1 ? String(parts[1]) : ""
guard path.hasPrefix("/oauth/google?") else {
c.send(content: Data("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), completion: .contentProcessed { _ in c.cancel() })
return
}
let reply = "HTTP/1.1 302 Found\r\nLocation: backplane://google\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
c.send(content: Data(reply.utf8), completion: .contentProcessed { _ in c.cancel() })
self.act?("google", "done:http://127.0.0.1:\(self.port)\(path)")
self.listener?.cancel()
self.listener = nil
}
}
}

func stop() {
listener?.cancel()
listener = nil
session = nil
opened = false
port = 0
}

nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
MainActor.assumeIsolated {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor()
}
}
}
34 changes: 26 additions & 8 deletions src/core/client.bend
Original file line number Diff line number Diff line change
Expand Up @@ -5171,20 +5171,38 @@ def Bots.token.clear(u: Ui) -> Ui:
def Bots.token(u: Ui, +hook: String, +op: String) -> Act:
Ui.rpc(Bots.token.clear(u), "bots.hook_token", [J.KV{"hook", J.Str{hook}}, J.KV{"op", J.Str{op}}])

def Bots.google.rpc(+u: Ui, +op: String, +account: String) -> Act:
def Bots.google.rpc.x(+u: Ui, +op: String, +account: String, +pasted: String, +redirect: String) -> Act:
Ui.rpc(u, "bots.google", [J.KV{"op", J.Str{op}}, J.KV{"clientId", J.Str{Bots.field(u, "gid")}}, J.KV{"clientSecret", J.Str{Bots.field(u, "gsecret")}},
J.KV{"pasted", J.Str{Bots.field(u, "gpaste")}}, J.KV{"account", J.Str{account}}])
J.KV{"pasted", J.Str{pasted}}, J.KV{"account", J.Str{account}}, J.KV{"redirect", J.Str{redirect}}])

# "signout:<address>" signs one account out; "disconnect" signs out every one
def Bots.google.k(one: Bool, +u: Ui, +op: String) -> Act:
match one:
case True{}:
def Bots.google.rpc(+u: Ui, +op: String, +account: String) -> Act:
Bots.google.rpc.x(u, op, account, Bots.field(u, "gpaste"), "")

# A phone signs in through a loopback listener of its own (docs/bots.md
# "Google"): "connect@<port>" begins with Google sending its browser back
# to http://127.0.0.1:<port>/oauth/google, and "done:<address>" finishes
# with the address the listener caught.
def Bots.google.loop(+port: String) -> String:
"http://127.0.0.1:" ++ port ++ "/oauth/google"

def Bots.google.op(i: Nat, +u: Ui, +op: String) -> Act:
match i:
case 0n:
Bots.google.rpc(u, "disconnect", String.drop(op, 8n))
case False{}:
case 1n:
Bots.google.rpc.x(u, "connect", "", "", Bots.google.loop(String.drop(op, 8n)))
case 2n:
Bots.google.rpc.x(u, "finish", "", String.drop(op, 5n), "")
case _:
Bots.google.rpc(u, op, "")

# "signout:<address>" signs one account out; "disconnect" signs out every one
def Bots.google.at(+op: String) -> Nat:
Bool.pick(Nat, String.starts_with(op, "signout:"), 0n,
Bool.pick(Nat, String.starts_with(op, "connect@"), 1n, Bool.pick(Nat, String.starts_with(op, "done:"), 2n, 3n)))

def Bots.google(+u: Ui, +op: String) -> Act:
Bots.google.k(String.starts_with(op, "signout:"), u, op)
Bots.google.op(Bots.google.at(op), u, op)

def Bots.google.keep(empty: Bool, x: String, rest: List<&2, String>) -> List<&2, String>:
match empty:
Expand Down
28 changes: 28 additions & 0 deletions src/core/google.bend
Original file line number Diff line number Diff line change
Expand Up @@ -1362,3 +1362,31 @@ def Google.hop(+query: String, +mine: String) -> String:
Google.hop.k(Bool.and(Bool.not(String.eq(Url.param(query, "state"), mine)),
Bool.and(Bool.not(String.eq(Url.param(query, "hop"), "1")), Google.hop.ok(Google.state.home(Url.param(query, "state"))))),
Google.state.home(Url.param(query, "state")), query)

# A phone's own sign-in (RFC 8252 7.3): the app listens on a loopback port
# of its own and asks the hub to begin with that redirect, so Google sends
# the phone's browser back to the app, which hands the address it caught
# to the hub to finish. The hub takes only exactly
# http://127.0.0.1:<1-5 digits>/oauth/google; anything else begins with its
# own redirect.
def Google.digits(s: String) -> Bool:
match s:
case SNil{}:
True{}
case SCon{c, t}:
Bool.and(Char.is_digit(c), Google.digits(t))

def Google.loopback.port(+p: String) -> Bool:
Bool.and(Bool.and(Nat.is_gt(String.length(p), 0n), Nat.is_le(String.length(p), 5n)), Google.digits(p))

def Google.loopback.mid(+url: String) -> String:
String.take(String.drop(url, 17n), Nat.sub(String.length(url), 30n))

def Google.loopback(+url: String) -> Bool:
Bool.and(Bool.and(String.starts_with(url, "http://127.0.0.1:"), String.ends_with(url, "/oauth/google")),
Bool.and(Nat.is_gt(String.length(url), 30n), Google.loopback.port(Google.loopback.mid(url))))

# the redirect a sign-in begins with: the one asked for when it is a
# loopback, else the hub's own
def Google.redirect.pick(+asked: String, +own: String) -> String:
Bool.pick(String, Google.loopback(asked), asked, own)
Loading
Loading