okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petauth: pass unencoded credential map in oauth exchange3b0866f

main
5.5 KiB187 linesraw
1// OAuth callback races, state validation, and fixed-port contention are timing-sensitive and cannot be scripted as an E2E model turn.
2import albedo/daemon/http_api
3import albedo/harness/oauth
4import gleam/dict
5import gleam/dynamic
6import gleam/dynamic/decode
7import gleam/erlang/process
8import gleam/json
9import gleam/list
10import gleam/option.{Some}
11import gleam/result
12import gleam/string
13import gleam/uri
14
15fn fake(callback: oauth.Callback) -> oauth.Login {
16  oauth.Login(
17    "fake",
18    "add fake account",
19    "",
20    oauth.Model,
21    "fake-accounts",
22    callback,
23    fn(grant) {
24      "https://auth.example/authorize?"
25      <> uri.query_to_string([
26        #("redirect_uri", grant.redirect),
27        #("state", grant.state),
28      ])
29    },
30    fn(_grant, code, progress) {
31      progress("provisioning " <> code)
32      case code {
33        "bad" -> Error("exchange rejected")
34        _ ->
35          Ok(
36            dict.from_list([
37              #("type", dynamic.string("oauth")),
38              #("access", dynamic.string("access-" <> code)),
39              #("email", dynamic.string(code <> "@example.com")),
40            ]),
41          )
42      }
43    },
44    fn(credential) {
45      let email =
46        decode.run(credential, decode.at(["email"], decode.string))
47        |> result.unwrap("")
48      let selected =
49        decode.run(credential, decode.at(["selected"], decode.bool))
50        |> result.unwrap(False)
51      oauth.Account(email, email, "fake account", selected)
52    },
53  )
54}
55
56fn ephemeral() -> oauth.Login {
57  fake(oauth.Callback("127.0.0.1", 0, "/cb", False))
58}
59
60fn query(url: String) -> List(#(String, String)) {
61  let assert Ok(parsed) = uri.parse(url)
62  let assert Ok(pairs) = uri.parse_query(option.unwrap(parsed.query, ""))
63  pairs
64}
65
66fn start(home: String, login: oauth.Login) -> #(String, String) {
67  let id = identity()
68  let assert Ok(#(True, flow)) =
69    oauth.start_identified(
70      home,
71      id,
72      "fake",
73      Some(login),
74      "{\"provider\":\"fake\"}",
75    )
76  #(id, flow)
77}
78
79fn field(flow: String, key: String) -> String {
80  let assert Ok(value) =
81    json.parse(flow, decode.field(key, decode.string, decode.success))
82  value
83}
84
85fn settle(
86  home: String,
87  id: String,
88  login: oauth.Login,
89  attempts: Int,
90) -> String {
91  let assert Ok(flow) = oauth.get_identified(home, id, [login])
92  case field(flow, "state"), attempts {
93    "waiting", n | "exchanging", n if n > 0 -> {
94      process.sleep(20)
95      settle(home, id, login, n - 1)
96    }
97    _, _ -> flow
98  }
99}
100
101pub fn a_pasted_redirect_races_the_callback_test() -> Nil {
102  let #(root, _, home) = fixture()
103  let login = ephemeral()
104  let #(id, flow) = start(home, login)
105  let assert Ok(state) = list.key_find(query(field(flow, "url")), "state")
106  let assert Ok(_) =
107    oauth.input_identified(
108      home,
109      id,
110      http_api.etag(flow),
111      "http://127.0.0.1:9/cb?code=cy&state=" <> state,
112      [login],
113    )
114  let complete = settle(home, id, login, 100)
115  assert field(complete, "state") == "complete"
116  let assert [account] = oauth.accounts(home, login)
117  assert account.label == "cy@example.com"
118  // Terminal outcome remains durable after the flow process has stopped.
119  let assert Ok(retained) = oauth.get_identified(home, id, [login])
120  assert retained == complete
121  let assert Ok(snapshot) = oauth.auth_snapshot(home, [login])
122  let assert Ok([provider_kind]) =
123    json.parse(
124      snapshot,
125      decode.at(["providers"], decode.list(decode.at(["kind"], decode.string))),
126    )
127  assert provider_kind == "model"
128  let assert Ok([account_kind]) =
129    json.parse(
130      snapshot,
131      decode.at(["accounts"], decode.list(decode.at(["kind"], decode.string))),
132    )
133  assert account_kind == "model"
134  cleanup(root)
135}
136
137pub fn a_pasted_code_for_another_attempt_is_rejected_test() -> Nil {
138  let #(root, _, home) = fixture()
139  let login = ephemeral()
140  let #(id, flow) = start(home, login)
141  let assert Ok(_) =
142    oauth.input_identified(home, id, http_api.etag(flow), "cy#not-this-state", [
143      login,
144    ])
145  let failed = settle(home, id, login, 100)
146  assert field(failed, "state") == "failed"
147  assert field(failed, "failure") == "Provider authorization failed."
148  assert oauth.accounts(home, login) == []
149  cleanup(root)
150}
151
152pub fn a_busy_fixed_port_fails_instead_of_moving_test() -> Nil {
153  let taken = occupy(0)
154  let port = taken.1
155  let #(root, _, home) = fixture()
156  let fixed = fake(oauth.Callback("127.0.0.1", port, "/cb", True))
157  let #(_, failed) = start(home, fixed)
158  assert field(failed, "state") == "failed"
159  assert string.contains(field(failed, "failure"), "callback listener")
160  let login = fake(oauth.Callback("127.0.0.1", port, "/cb", False))
161  let #(id, flow) = start(home, login)
162  let assert Ok(redirect) =
163    list.key_find(query(field(flow, "url")), "redirect_uri")
164  assert !string.contains(redirect, ":" <> int_to_string(port) <> "/")
165  let assert Ok(cancelled) = oauth.cancel_identified(home, id, [login])
166  assert field(cancelled, "state") == "cancelled"
167  release(taken)
168  cleanup(root)
169}
170
171@external(erlang, "albedo_oauth_test_support", "identity")
172fn identity() -> String
173
174@external(erlang, "erlang", "integer_to_binary")
175fn int_to_string(value: Int) -> String
176
177@external(erlang, "albedo_oauth_test_support", "occupy")
178fn occupy(port: Int) -> #(dynamic.Dynamic, Int)
179
180@external(erlang, "albedo_oauth_test_support", "release")
181fn release(taken: #(dynamic.Dynamic, Int)) -> Nil
182
183@external(erlang, "albedo_skills_test_support", "fixture")
184fn fixture() -> #(String, String, String)
185
186@external(erlang, "albedo_skills_test_support", "cleanup")
187fn cleanup(root: String) -> Nil