| Sign in with Bluesky, through the broker bdd2c3c nandi 23h ago | 1 | ## The broker flow, minus the browser: a URL in, a payload out. |
| 2 | ## |
| 3 | ## No network and no socket here. What is worth testing about this module is |
| 4 | ## the encoding either end has to agree on — a handle in a query string, a |
| 5 | ## base64url payload, a Content-Length header — and all of it is a string in |
| 6 | ## and a string out. |
| 7 | |
| 8 | import std/[base64, httpclient, json, os, strutils, unittest] |
| 9 | import frq/oauth |
| 10 | |
| 11 | suite "urlEncode": |
| 12 | test "the unreserved set goes through untouched": |
| 13 | check urlEncode("alice.bsky.social") == "alice.bsky.social" |
| 14 | check urlEncode("a-z_0.9~") == "a-z_0.9~" |
| 15 | test "everything else is percent-encoded, in upper-case hex": |
| 16 | check urlEncode("a b") == "a%20b" |
| 17 | check urlEncode("a/b?c=d&e") == "a%2Fb%3Fc%3Dd%26e" |
| 18 | check urlEncode("@alice") == "%40alice" |
| 19 | test "a non-ASCII handle is encoded per byte, not per character": |
| 20 | # é is two bytes in UTF-8, and a percent-encoder that passes it through |
| 21 | # has not encoded anything. |
| 22 | check urlEncode("café") == "caf%C3%A9" |
| 23 | |
| 24 | suite "loginUrl": |
| 25 | test "handle and return_to are both encoded": |
| 26 | check loginUrl("https://auth.freeq.at", "alice.bsky.social", |
| 27 | "http://127.0.0.1:7391") == |
| 28 | "https://auth.freeq.at/auth/login?handle=alice.bsky.social" & |
| 29 | "&return_to=http%3A%2F%2F127.0.0.1%3A7391" |
| 30 | test "a trailing slash on the broker is not doubled": |
| 31 | check loginUrl("https://auth.freeq.at/", "a.uk", "x").startsWith( |
| 32 | "https://auth.freeq.at/auth/login?") |
| 33 | test "an empty broker is the default one": |
| 34 | check loginUrl("", "a.uk", "x").startsWith(defaultBroker & "/auth/login?") |
| 35 | test "a leading @ is how it is written beside a message, not part of it": |
| 36 | check "handle=alice.uk&" in loginUrl("", "@alice.uk", "x") |
| 37 | test "and so is the whitespace around a pasted handle": |
| 38 | check "handle=alice.uk&" in loginUrl("", " alice.uk ", "x") |
| 39 | |
| 40 | suite "brokerHost": |
| 41 | test "the host alone, whatever the scheme": |
| 42 | check brokerHost("https://auth.freeq.at") == "auth.freeq.at" |
| 43 | check brokerHost("http://localhost:8080/x") == "localhost:8080" |
| 44 | check brokerHost("") == "auth.freeq.at" |
| 45 | |
| 46 | proc payload(j: JsonNode): string = |
| 47 | ## What the broker puts in the fragment: base64url, unpadded. |
| 48 | encode($j).replace("+", "-").replace("/", "_").replace("=", "") |
| 49 | |
| 50 | suite "tokensOf": |
| 51 | test "a full payload becomes fields": |
| 52 | let t = tokensOf(payload(%*{"token": "web", "broker_token": "durable", |
| 53 | "nick": "alice", "did": "did:plc:a", |
| 54 | "handle": "alice.uk"})) |
| 55 | check t.token == "web" |
| 56 | check t.brokerToken == "durable" |
| 57 | check t.nick == "alice" |
| 58 | check t.did == "did:plc:a" |
| 59 | check t.handle == "alice.uk" |
| 60 | test "surrounding whitespace is the browser's, not the payload's": |
| 61 | check tokensOf(" " & payload(%*{"token": "a", "broker_token": "b"}) & |
| 62 | "\n").token == "a" |
| 63 | test "either token missing is a failure, not a half sign-in": |
| 64 | expect OauthError: discard tokensOf(payload(%*{"token": "web"})) |
| 65 | expect OauthError: discard tokensOf(payload(%*{"broker_token": "d"})) |
| 66 | test "and the broker's own reason is what gets raised": |
| 67 | try: |
| 68 | discard tokensOf(payload(%*{"error": "that handle has no account"})) |
| 69 | check false |
| 70 | except OauthError as e: |
| 71 | check e.msg == "that handle has no account" |
| 72 | test "something that is not base64url JSON at all": |
| 73 | expect OauthError: discard tokensOf("not-a-payload") |
| 74 | expect OauthError: discard tokensOf("") |
| 75 | |
| 76 | suite "contentLengthOf": |
| 77 | test "the header, however the client capitalised it": |
| 78 | check contentLengthOf("POST /capture\r\nContent-Length: 42\r\n\r\n") == 42 |
| 79 | check contentLengthOf("POST /capture\r\ncontent-length: 7\r\n\r\n") == 7 |
| 80 | test "no header is no body": |
| 81 | check contentLengthOf("GET / HTTP/1.1\r\nHost: x\r\n\r\n") == 0 |
| 82 | test "and a header that is not a number does not throw": |
| 83 | check contentLengthOf("POST /\r\nContent-Length: banana\r\n\r\n") == 0 |
| 84 | |
| 85 | suite "httpResponse": |
| 86 | test "the length is the body's, in bytes": |
| 87 | let r = httpResponse("200 OK", "text/plain", "héllo") |
| 88 | check "Content-Length: 6" in r # é is two bytes |
| 89 | check r.startsWith("HTTP/1.1 200 OK\r\n") |
| 90 | check r.endsWith("\r\n\r\nhéllo") |
| 91 | |
| 92 | suite "captureHtml": |
| 93 | test "posts the fragment back, because a fragment never reaches a server": |
| 94 | let h = captureHtml() |
| 95 | check "location.hash" in h |
| 96 | check "'/capture'" in h |
| 97 | check "method:'POST'" in h |
| 98 | |
| 99 | suite "the loopback listener": |
| 100 | # The one part of this that is not a string in and a string out. It binds a |
| 101 | # port, serves the capture page, and waits — so the test is a real browser's |
| 102 | # side of the handoff: fetch the page, post the fragment back, and see the |
| 103 | # tokens come out of the channel. |
| 104 | # |
| 105 | # No browser is opened. `begin` takes that as a parameter for this test |
| 106 | # alone; the URL it would have opened comes out on the channel regardless, |
| 107 | # and is what these requests are aimed at. |
| 108 | test "serves the page, ignores junk, and completes on a real payload": |
| 109 | let good = payload(%*{"token": "web", "broker_token": "durable", |
| 110 | "nick": "alice", "did": "did:plc:a", |
| 111 | "handle": "alice.uk"}) |
| 112 | begin(defaultBroker, "alice.uk", openBrowser = false) |
| 113 | defer: finished() |
| 114 | |
| 115 | var url: string |
| 116 | for _ in 0 .. 200: |
| 117 | let (ok, e) = tryEvent() |
| 118 | if ok and e.startsWith("url: "): url = e[5 .. ^1]; break |
| 119 | sleep(25) |
| 120 | require url.len > 0 |
| 121 | |
| 122 | # The `return_to` we handed the broker is the address to talk to. |
| 123 | let here = url.split("return_to=")[1] |
| 124 | .replace("%3A", ":").replace("%2F", "/") |
| 125 | let c = newHttpClient(timeout = 5000) |
| 126 | defer: c.close() |
| 127 | |
| 128 | # A GET is the browser landing on us: it gets the page whose script posts |
| 129 | # the fragment back. |
| 130 | check "location.hash" in c.getContent(here) |
| 131 | |
| 132 | # A POST carrying nothing usable is not the end of the wait — the real |
| 133 | # handoff may still be on its way. |
| 134 | check c.request(here & "/capture", httpMethod = HttpPost, |
| 135 | body = "garbage").status.startsWith("400") |
| 136 | check not tryEvent()[0] |
| 137 | |
| 138 | check c.request(here & "/capture", httpMethod = HttpPost, |
| 139 | body = good).body == "ok" |
| 140 | var got: string |
| 141 | for _ in 0 .. 200: |
| 142 | let (ok, e) = tryEvent() |
| 143 | if ok: got = e; break |
| 144 | sleep(25) |
| 145 | require got.startsWith("ok: ") |
| 146 | check tokensOf(got[4 .. ^1]).brokerToken == "durable" |