// The GitHub Copilot device flow is provider wire behaviour: GitHub's origin // rules, the RFC 8628 forms, and the token error table only show against an // OAuth server that answers like GitHub, which a scripted model turn never // reaches. Every request here stays on 127.0.0.1. import gleam/list import gleam/string import gleeunit/should type Flow = #( Result(#(String, String), String), Result(String, String), String, List(String), ) pub fn endpoint_normalises_https_origins_test() -> Nil { endpoint("https://github.com") |> should.equal(Ok("https://github.com")) endpoint("https://github.com/") |> should.equal(Ok("https://github.com")) endpoint("github.com") |> should.equal(Ok("https://github.com")) endpoint("HTTPS://GitHub.COM") |> should.equal(Ok("https://github.com")) endpoint(" https://github.com:443 ") |> should.equal(Ok("https://github.com")) endpoint("https://github.com:8443") |> should.equal(Ok("https://github.com:8443")) } pub fn endpoint_keeps_an_absolute_path_prefix_test() -> Nil { endpoint("https://ghe.example.com/api/v3") |> should.equal(Ok("https://ghe.example.com/api/v3")) endpoint("https://ghe.example.com/api/v3/") |> should.equal(Ok("https://ghe.example.com/api/v3")) endpoint("ghe.example.com/api/v3") |> should.equal(Ok("https://ghe.example.com/api/v3")) endpoint("https://fake.local/t/route") |> should.equal(Ok("https://fake.local/t/route")) } pub fn endpoint_rejects_queries_fragments_and_user_info_test() -> Nil { endpoint("https://github.com?a=1") |> should.equal(Error("origin must not include a query")) endpoint("https://github.com#section") |> should.equal(Error("origin must not include a fragment")) endpoint("https://user@github.com") |> should.equal(Error("origin must not include user info")) endpoint("https://github.com?") |> should.equal(Error("origin must not include a query")) endpoint("https://github.com#") |> should.equal(Error("origin must not include a fragment")) endpoint("https://@github.com") |> should.equal(Error("origin must not include user info")) endpoint("https://") |> should.equal(Error("origin is not a valid url")) endpoint("") |> should.equal(Error("origin is empty")) endpoint(" ") |> should.equal(Error("origin is empty")) } pub fn endpoint_allows_http_only_on_loopback_hosts_test() -> Nil { endpoint("http://localhost:8123") |> should.equal(Ok("http://localhost:8123")) endpoint("http://127.0.0.1:9") |> should.equal(Ok("http://127.0.0.1:9")) endpoint("http://[::1]:9000") |> should.equal(Ok("http://[::1]:9000")) endpoint("http://example.com") |> should.equal(Error( "http origins are allowed only on loopback hosts; use https", )) endpoint("ftp://github.com") |> should.equal(Error("origin must use https (or http on a loopback host)")) } pub fn account_names_the_login_and_marks_selection_test() -> Nil { let public = "{\"type\":\"oauth\",\"access\":\"t\"," <> "\"accountId\":\"https://api.github.com#170000\",\"login\":\"octocat\"," <> "\"endpoint\":\"https://api.githubcopilot.com\"}" account(public) |> should.equal(#( "https://api.github.com#170000", "octocat", "github copilot account", False, )) let private = "{\"accountId\":\"https://ghe.example.com#7\",\"login\":\"octo\"," <> "\"endpoint\":\"https://copilot-api.ghe.example.com\",\"selected\":true}" account(private) |> should.equal(#( "https://ghe.example.com#7", "octo", "github copilot account · selected · https://copilot-api.ghe.example.com", True, )) } pub fn device_flow_authorizes_and_stores_the_account_test() -> Nil { let #(start, complete, requests, progress) = flow("device,token,user,copilot") let assert Ok(#(url, instructions)) = start string.contains(url, "/login/device") |> should.be_true string.contains(instructions, "ABCD-1234") |> should.be_true let assert Ok(credential) = complete string.contains(credential, "\"access\":\"gho_test_token\"") |> should.be_true string.contains(credential, "#170000\"") |> should.be_true string.contains(credential, "\"login\":\"octocat\"") |> should.be_true string.contains(credential, "\"endpoint\":\"https://api.githubcopilot.com\"") |> should.be_true // The stored credential is the GitHub token itself: no invented lifecycle. string.contains(credential, "refresh") |> should.be_false string.contains(credential, "expires") |> should.be_false // RFC 8628 wire forms. string.contains(requests, "\"path\":\"/login/device/code\"") |> should.be_true string.contains( requests, "\"body\":\"client_id=test-client&scope=read%3Auser\"", ) |> should.be_true string.contains( requests, "\"content_type\":\"application/x-www-form-urlencoded\"", ) |> should.be_true string.contains( requests, "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code", ) |> should.be_true // GitHub REST calls carry the token and albedo's own identity. string.contains(requests, "\"authorization\":\"token gho_test_token\"") |> should.be_true string.contains(requests, "\"user_agent\":\"albedo/1.0\"") |> should.be_true string.contains(requests, "\"path\":\"/user\"") |> should.be_true string.contains(requests, "\"path\":\"/copilot_internal/user\"") |> should.be_true let assert [waiting, reading] = progress string.starts_with(waiting, "Waiting for authorization") |> should.be_true string.starts_with(reading, "Reading your GitHub account") |> should.be_true } pub fn device_flow_polls_until_github_authorizes_test() -> Nil { let #(start, complete, requests, _) = flow("device,pending,token,user,copilot") let assert Ok(_) = start let assert Ok(_) = complete // One POST while pending, one after: the pending answer was polled, not // accepted. poll_count(requests) |> should.equal(2) } pub fn device_flow_keeps_polling_after_slow_down_test() -> Nil { let #(start, complete, requests, _) = flow("device,slow,slow,token,user,copilot") let assert Ok(_) = start let assert Ok(_) = complete poll_count(requests) |> should.equal(3) } pub fn device_flow_reports_denied_authorization_test() -> Nil { let #(start, complete, _, _) = flow("device,denied") let assert Ok(_) = start let assert Error(reason) = complete string.contains(reason, "denied") |> should.be_true } pub fn device_flow_expires_when_the_device_code_lapses_test() -> Nil { let #(start, complete, _, _) = flow("device_short,pending") let assert Ok(_) = start let assert Error(reason) = complete string.contains(reason, "expired") |> should.be_true } pub fn device_flow_refuses_a_foreign_verification_url_test() -> Nil { let #(start, complete, _, _) = flow("device_bad_uri") let assert Error(reason) = start string.contains(reason, "verification url outside its own origin") |> should.be_true let assert Error(_) = complete Nil } pub fn device_flow_requires_device_and_user_codes_test() -> Nil { let #(start, _, _, _) = flow("device_incomplete") let assert Error(reason) = start string.contains(reason, "incomplete") |> should.be_true } pub fn device_flow_rejects_unsuccessful_tokens_and_fractional_fields_test() -> Nil { let #(start, complete, _, _) = flow("device,token_failed") let assert Ok(_) = start let assert Error(reason) = complete string.contains(reason, "503") |> should.be_true string.contains(reason, "response-secret") |> should.be_false let #(fractional, _, _, _) = flow("device_fractional") let assert Error(interval_error) = fractional string.contains(interval_error, "interval or expiry") |> should.be_true let #(_, user, _, _) = flow("device,token,user_fractional") let assert Error(user_error) = user string.contains(user_error, "GitHub user") |> should.be_true } pub fn device_flow_falls_back_when_discovery_is_unavailable_test() -> Nil { let #(start, missing, _, _) = flow("device,token,user,copilot_404") let assert Ok(_) = start let assert Ok(credential) = missing string.contains(credential, "\"endpoint\":\"https://fallback.example.com\"") |> should.be_true // A malformed endpoint from discovery is never accepted either. let #(start2, malformed, _, _) = flow("device,token,user,copilot_malformed") let assert Ok(_) = start2 let assert Ok(credential2) = malformed string.contains(credential2, "\"endpoint\":\"https://fallback.example.com\"") |> should.be_true } pub fn device_flow_requires_the_github_user_test() -> Nil { let #(start, complete, _, _) = flow("device,token,user_404") let assert Ok(_) = start let assert Error(reason) = complete string.contains(reason, "GitHub user") |> should.be_true string.contains(reason, "GITHUB_COPILOT_GITHUB_API") |> should.be_true // No raw provider body leaks into a user-facing error. string.contains(reason, "Not Found") |> should.be_false } pub fn environment_overrides_reach_the_flow_test() -> Nil { let #(start, complete, requests, _) = env_flow("device,token,user,copilot_private") let assert Ok(_) = start let assert Ok(credential) = complete string.contains( credential, "\"endpoint\":\"https://copilot-api.ghe.example.com\"", ) |> should.be_true string.contains(requests, "client_id=env-client") |> should.be_true } pub fn environment_rejects_a_malformed_origin_test() -> Nil { let reason = env_bad_origin() string.contains(reason, "GITHUB_COPILOT_OAUTH_SERVER") |> should.be_true string.contains(reason, "query") |> should.be_true } fn poll_count(requests: String) -> Int { { requests |> string.split("grant_type=urn%3Aietf") |> list.length } - 1 } @external(erlang, "albedo_github_copilot_auth_test_support", "flow") fn flow(script: String) -> Flow @external(erlang, "albedo_github_copilot_auth_test_support", "env_flow") fn env_flow(script: String) -> Flow @external(erlang, "albedo_github_copilot_auth_test_support", "env_bad_origin") fn env_bad_origin() -> String @external(erlang, "albedo_github_copilot_auth_test_support", "endpoint") fn endpoint(origin: String) -> Result(String, String) @external(erlang, "albedo_github_copilot_auth_test_support", "account") fn account(credential: String) -> #(String, String, String, Bool)