nandi/frqpublic Fork 0
ecb440ffdd4c6db508af0732bc816f2dd1172f98
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

toauth.nim · 146 lines · 6.3 KBNim Blame HistoryRaw
Sign in with Bluesky, through the broker bdd2c3c nandi yesterday1## 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
8import std/[base64, httpclient, json, os, strutils, unittest]
9import frq/oauth
10
11suite "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
24suite "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
40suite "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
46proc payload(j: JsonNode): string =
47 ## What the broker puts in the fragment: base64url, unpadded.
48 encode($j).replace("+", "-").replace("/", "_").replace("=", "")
49
50suite "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
76suite "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
85suite "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
92suite "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
99suite "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"