diff --git a/README.md b/README.md index d8b736d..acb77fc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Run the tool from a checkout: gleam run ``` -It prompts for your handle and app password, prints the accounts it found, and asks for confirmation before creating any Tangled follows. +It prompts for your handle, opens a browser-based OAuth authorization flow with a localhost callback, prints the accounts it found, and asks for confirmation before creating any Tangled follows. It requests `atproto repo:sh.tangled.graph.follow`; reading the public follow lists does not require additional scopes. ## License diff --git a/gleam.toml b/gleam.toml index 98c4a06..edf9ba2 100644 --- a/gleam.toml +++ b/gleam.toml @@ -15,6 +15,10 @@ gleam_http = ">= 4.3.0 and < 5.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" gleam_httpc = ">= 5.0.0 and < 6.0.0" gleam_erlang = ">= 1.3.0 and < 2.0.0" +kryptos = ">= 1.4.0 and < 2.0.0" +gose = ">= 2.1.0 and < 3.0.0" +gleam_time = ">= 1.8.0 and < 2.0.0" +mist = ">= 6.0.3 and < 7.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 6b81e85..de2ca1c 100644 --- a/manifest.toml +++ b/manifest.toml @@ -7,13 +7,25 @@ # You should check this file into your source control repository. packages = [ + { name = "bigi", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "bigi", source = "hex", outer_checksum = "44FEDF1DF5C1F426A80B67C32B976D0828D66524E11BDC2DD22B78D37C883BA7" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gose", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time", "kryptos"], otp_app = "gose", source = "hex", outer_checksum = "F1F172D428A07BC6F3229F8363B85E6E8C79A5E9E28F27B07C2EFBF1F6B08114" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "kryptos", version = "1.4.0", build_tools = ["gleam"], requirements = ["bigi", "gleam_stdlib", "gleam_time"], otp_app = "kryptos", source = "hex", outer_checksum = "734DAB837962A4A45788F8E6350EF4A6DF20A1F54734736CD1FF3A03FFCE4C97" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, { name = "possum", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_json", "gleam_regexp", "gleam_stdlib"], otp_app = "possum", source = "hex", outer_checksum = "5B7FDDDE6A845CE864973F70CF56B6EC9DA0C040C0EC5FD85A002475DF079277" }, ] @@ -23,5 +35,9 @@ gleam_http = { version = ">= 4.3.0 and < 5.0.0" } gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleam_time = { version = ">= 1.8.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +gose = { version = ">= 2.1.0 and < 3.0.0" } +kryptos = { version = ">= 1.4.0 and < 2.0.0" } +mist = { version = ">= 6.0.3 and < 7.0.0" } possum = { version = ">= 3.0.0 and < 4.0.0" } diff --git a/src/atproto_records.gleam b/src/atproto_records.gleam new file mode 100644 index 0000000..1633de1 --- /dev/null +++ b/src/atproto_records.gleam @@ -0,0 +1,74 @@ +import gleam/http/request +import gleam/http/response +import gleam/httpc +import gleam/json + +import gleam/option +import gleam/uri +import possum +import possum/did +import possum/nsid + +import oauth + +pub type CreateRecordError { + RequestFailed + UnexpectedStatus(Int) +} + +pub fn create_record( + account_did: did.Did, + account_pds: String, + collection_name: String, + record: List(#(String, json.Json)), + credential: oauth.OAuthCredential, +) -> Result(oauth.OAuthCredential, CreateRecordError) { + let assert Ok(collection) = nsid.parse(collection_name) + let request = + possum.create_record( + account_did, + pds: account_pds, + collection:, + rkey: option.None, + record:, + swap_commit: option.None, + validate: option.None, + ) + let request_url = + request + |> request.to_uri + |> uri.to_string + let dpop_proof = + oauth.create_dpop_proof( + credential.dpop_key, + "POST", + request_url, + credential.dpop_nonce, + option.Some(credential.access_token), + ) + let request = + request + |> request.set_header( + "authorization", + "DPoP " <> credential.access_token, + ) + |> request.set_header("dpop", dpop_proof) + + case httpc.send(request) { + Error(_) -> Error(RequestFailed) + + Ok(response) if response.status == 200 -> { + let dpop_nonce = + response.get_header(response, "dpop-nonce") + |> option.from_result + let credential = case dpop_nonce { + option.Some(_) -> oauth.with_dpop_nonce(credential, dpop_nonce) + option.None -> credential + } + + Ok(credential) + } + + Ok(response) -> Error(UnexpectedStatus(response.status)) + } +} diff --git a/src/entwine.gleam b/src/entwine.gleam index ca7e182..ca92a73 100644 --- a/src/entwine.gleam +++ b/src/entwine.gleam @@ -1,24 +1,35 @@ -import gleam/dynamic/decode import gleam/erlang/process -import gleam/httpc import gleam/int import gleam/io -import gleam/json import gleam/list import gleam/option +import gleam/uri import possum/did as at_did import possum/handle as at_handle import atproto import bsky -import possum +import oauth import tangled const max_concurrent_profile_checks = 40 +const oauth_scope = "atproto repo:sh.tangled.graph.follow" +const oauth_redirect_uri = "http://127.0.0.1:8080/callback" @external(erlang, "entwine_ffi", "prompt") fn prompt(message: String) -> String +fn oauth_client() -> oauth.OAuthClient { + let client_id = + "http://localhost?" + <> uri.query_to_string([ + #("redirect_uri", oauth_redirect_uri), + #("scope", oauth_scope), + ]) + + oauth.OAuthClient(client_id:, redirect_uri: oauth_redirect_uri, scope: oauth_scope) +} + pub fn find_accounts_to_follow( tangled_profile_holders: List(bsky.Follow), tangled_follows: List(tangled.Follow), @@ -81,31 +92,55 @@ fn collect_tangled_profiles( } } +fn create_tangled_follows( + account_did: at_did.Did, + account_pds: String, + credential: oauth.OAuthCredential, + follows: List(bsky.Follow), +) -> oauth.OAuthCredential { + case follows { + [] -> credential + + [follow, ..remaining] -> { + let credential = case + tangled.create_tangled_follow( + account_did, + account_pds, + credential, + follow.subject, + ) + { + Ok(updated_credential) -> { + io.println("Follow created") + updated_credential + } + + Error(error) -> { + io.println(tangled.create_follow_error_message(error)) + credential + } + } + + create_tangled_follows(account_did, account_pds, credential, remaining) + } + } +} + pub fn main() -> Nil { let raw_handle = prompt("Atmosphere handle: ") - let app_password = prompt("App password: ") - let assert True = app_password != "" - let did = atproto.get_did(raw_handle) let pds = atproto.get_pds(did) io.println("Resolved to " <> at_did.to_string(did) <> " on " <> pds <> ".") + io.println("Open the authorization URL in your browser to continue.") - let session_request = - possum.create_session( - did, - pds:, - app_password:, - allow_takendown: option.None, - auth_factor_token: option.None, + let credential = + oauth.authenticate( + "https://" <> pds, + at_did.to_string(did), + oauth_client(), ) - let assert Ok(session_response) = httpc.send(session_request) - - let access_token_decoder = decode.at(["accessJwt"], decode.string) - let assert Ok(access_token) = - json.parse(session_response.body, access_token_decoder) - - io.println("Authenticated successfully.") + io.println("Authenticated successfully as " <> credential.subject <> ".") let bsky_follows = bsky.get_bsky_follows(did, pds) let bsky_follow_count = list.length(bsky_follows) @@ -116,7 +151,6 @@ pub fn main() -> Nil { io.println(int.to_string(tngld_follow_count) <> " Tangled follow(s) found.") let tangled_profile_holders = find_tangled_profile_holders(bsky_follows) - let bsky_only_follows = find_accounts_to_follow(tangled_profile_holders, tngld_follows) @@ -139,15 +173,9 @@ pub fn main() -> Nil { case answer { "y" -> { - bsky_only_follows - |> list.each(fn(follow) { - case - tangled.create_tangled_follow(did, pds, access_token, follow.subject) - { - Ok(Nil) -> io.println("Follow created") - Error(error) -> io.println(tangled.create_follow_error_message(error)) - } - }) + let _credential = + create_tangled_follows(did, pds, credential, bsky_only_follows) + Nil } "n" -> io.println("Skipped.") diff --git a/src/entwine_ffi.erl b/src/entwine_ffi.erl index a198ced..5ed859e 100644 --- a/src/entwine_ffi.erl +++ b/src/entwine_ffi.erl @@ -1,5 +1,5 @@ -module(entwine_ffi). --export([prompt/1, now_rfc3339/0]). +-export([open_browser/1, prompt/1, now_rfc3339/0]). prompt(Message) -> case io:get_line(Message) of @@ -9,6 +9,21 @@ prompt(Message) -> end. +open_browser(Url) -> + Command = case os:type() of + {unix, darwin} -> "open"; + _ -> "xdg-open" + end, + case os:find_executable(Command) of + false -> nil; + Executable -> + _ = open_port( + {spawn_executable, Executable}, + [binary, {args, [Url]}] + ), + nil + end. + now_rfc3339() -> unicode:characters_to_binary( calendar:system_time_to_rfc3339( diff --git a/src/oauth.gleam b/src/oauth.gleam new file mode 100644 index 0000000..5e6c8e8 --- /dev/null +++ b/src/oauth.gleam @@ -0,0 +1,493 @@ +import gleam/dict +import gleam/dynamic/decode +import gleam/erlang/process +import gleam/http/request +import gleam/http/response +import gleam/http +import gleam/httpc +import gleam/json +import gleam/list +import gleam/option.{type Option} +import gleam/string +import gleam/uri +import gleam/time/timestamp +import gleam/bit_array + +import gose/jose/jwk +import gose/jose/jws +import gose + +import kryptos/crypto +import kryptos/ec +import kryptos/hash + +import oauth_callback + +@external(erlang, "entwine_ffi", "open_browser") +fn open_browser(url: String) -> Nil + +pub type OAuthServerMetadata { + OAuthServerMetadata( + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + pushed_authorization_request_endpoint: String, + dpop_signing_alg_values_supported: List(String), + ) +} + +pub type OAuthSession { + OAuthSession( + state: String, + issuer: String, + token_endpoint: String, + pkce_verifier: String, + dpop_key: gose.Key(String), + dpop_nonce: Option(String), + ) +} + +pub type ParResponse { + ParResponse( + request_uri: String, + expires_in: Int, + dpop_nonce: Option(String), + ) +} + +pub type TokenResponse { + TokenResponse( + access_token: String, + token_type: String, + expires_in: Int, + refresh_token: Option(String), + scope: String, + sub: String, + dpop_nonce: Option(String), + ) +} + +pub type OAuthClient { + OAuthClient(client_id: String, redirect_uri: String, scope: String) +} + +pub type OAuthCredential { + OAuthCredential( + access_token: String, + subject: String, + dpop_key: gose.Key(String), + dpop_nonce: Option(String), + ) +} + + + +pub fn with_dpop_nonce( + credential: OAuthCredential, + dpop_nonce: Option(String), +) -> OAuthCredential { + OAuthCredential(..credential, dpop_nonce:) +} + +fn random_string() -> String { + crypto.random_bytes(32) + |> bit_array.base64_url_encode(False) +} + +pub fn get_auth_server(pds: String) -> String { + let assert Ok(pds_uri) = uri.parse(pds) + let protected_resource_uri = + uri.Uri( + ..pds_uri, + path: "/.well-known/oauth-protected-resource", + query: option.None, + fragment: option.None, + ) + let assert Ok(metadata_request) = request.from_uri(protected_resource_uri) + let assert Ok(metadata_response) = httpc.send(metadata_request) + + let authorization_servers_decoder = + decode.at(["authorization_servers"], decode.list(decode.string)) + let assert Ok([authorization_server, ..]) = + json.parse(metadata_response.body, authorization_servers_decoder) + + authorization_server +} + +pub fn get_oauth_server_metadata(auth_server: String) -> OAuthServerMetadata { + let assert Ok(auth_uri) = uri.parse(auth_server) + let metadata_uri = + uri.Uri( + ..auth_uri, + path: "/.well-known/oauth-authorization-server", + query: option.None, + fragment: option.None, + ) + let assert Ok(metadata_request) = request.from_uri(metadata_uri) + let assert Ok(metadata_response) = httpc.send(metadata_request) + + let metadata_decoder = { + use issuer <- decode.field("issuer", decode.string) + use authoriization_endpoint <- decode.field( + "authorization_endpoint", + decode.string, + ) + use token_endpoint <- decode.field("token_endpoint", decode.string) + use par_endpoint <- decode.field( + "pushed_authorization_request_endpoint", + decode.string, + ) + use dpop_algorithms <- decode.field( + "dpop_signing_alg_values_supported", + decode.list(decode.string), + ) + + decode.success( + OAuthServerMetadata( + issuer: issuer, + authorization_endpoint: authoriization_endpoint, + token_endpoint: token_endpoint, + pushed_authorization_request_endpoint: par_endpoint, + dpop_signing_alg_values_supported: dpop_algorithms, + ) + ) + } + + let assert Ok(metadata) = json.parse(metadata_response.body, metadata_decoder) + metadata +} + +pub fn create_pkce() -> #(String, String) { + let verifier = random_string() + let assert Ok(h) = hash.new(hash.Sha256) + + let challenge = + h + |> hash.update(<>) + |> hash.final() + |> bit_array.base64_url_encode(False) + + #(verifier, challenge) +} + +pub fn create_dpop_key() -> gose.Key(String) { + gose.generate_ec(ec.P256) +} + +pub fn create_dpop_proof(key: gose.Key(String), method: String, url: String, nonce: Option(String), access_token: Option(String)) -> String { + let jti = random_string() + let #(iat, _) = timestamp.system_time() + |> timestamp.to_unix_seconds_and_nanoseconds() + + let payload = + [ + #("jti", json.string(jti)), + #("htm", json.string(method)), + #("htu", json.string(url)), + #("iat", json.int(iat)), + ] + + let payload = case nonce { + option.None -> payload + + option.Some(nonce_value) -> + [#("nonce", json.string(nonce_value)), ..payload] + } + + let payload = case access_token { + option.None -> payload + + option.Some(access_token) -> { + let assert Ok(h) = hash.new(hash.Sha256) + + let access_token_hash = + h + |> hash.update(<>) + |> hash.final() + |> bit_array.base64_url_encode(False) + + [#("ath", json.string(access_token_hash)), ..payload] + } + } + + let payload_bits = + payload + |> json.object + |> json.to_string + |> bit_array.from_string + + let assert Ok(public_key) = gose.public_key(key) + let public_jwk = jwk.to_json(public_key) + + let assert Ok(unsigned) = + jws.new(gose.DigitalSignature(gose.Ecdsa(gose.EcdsaP256))) + |> jws.with_typ("dpop+jwt") + |> jws.with_header("jwk", public_jwk) + + let assert Ok(signed) = + jws.sign(unsigned, key: key, payload: payload_bits) + + let assert Ok(dpop_proof) = jws.serialize_compact(signed) + dpop_proof +} + +pub fn push_authorization_request(par_endpoint: String, client_id: String, redirect_uri: String, scope: String, state: String, code_challenge: String, dpop_key: gose.Key(String)) -> ParResponse { + let dpop_proof = create_dpop_proof( + dpop_key, + "POST", + par_endpoint, + option.None, + option.None, + ) + + let form_body = + uri.query_to_string([ + #("client_id", client_id), + #("response_type", "code"), + #("redirect_uri", redirect_uri), + #("scope", scope), + #("state", state), + #("code_challenge", code_challenge), + #("code_challenge_method", "S256"), + ]) + + let assert Ok(par_uri) = uri.parse(par_endpoint) + let assert Ok(par_request) = request.from_uri(par_uri) + + let par_request = + request.Request( + ..par_request, + method: http.Post, + body: form_body, + ) + + let par_request = + par_request + |> request.set_header( + "content-type", + "application/x-www-form-urlencoded", + ) + |> request.set_header("dpop", dpop_proof) + + let assert Ok(par_response) = httpc.send(par_request) + + let par_response_decoder = { + use request_uri <- decode.field("request_uri", decode.string) + use expires_in <- decode.field("expires_in", decode.int) + + decode.success(#(request_uri, expires_in)) + } + + let assert Ok(#(request_uri, expires_in)) = json.parse(par_response.body, par_response_decoder) + + let dpop_nonce = + response.get_header(par_response, "dpop-nonce") + |> option.from_result + + ParResponse(request_uri:, expires_in:, dpop_nonce:) +} + + + +pub fn build_authorization_url( + authorization_endpoint: String, + client_id: String, + request_uri: String, +) -> String { + let query = + uri.query_to_string([ + #("client_id", client_id), + #("request_uri", request_uri), + ]) + + authorization_endpoint <> "?" <> query +} + +pub fn save_oauth_session( + sessions: dict.Dict(String, OAuthSession), + session: OAuthSession, +) -> dict.Dict(String, OAuthSession) { + let OAuthSession(state:, ..) = session + dict.insert(sessions, state, session) +} + +pub fn get_oauth_session( + sessions: dict.Dict(String, OAuthSession), + state: String, +) -> Option(OAuthSession) { + dict.get(sessions, state) + |> option.from_result +} + +pub fn parse_callback_url(callback_url: String) -> #(String, String, String) { + let assert Ok(callback_uri) = uri.parse(callback_url) + let assert option.Some(query) = callback_uri.query + let assert Ok(parameters) = uri.parse_query(query) + + let assert Ok(code) = list.key_find(parameters, "code") + let assert Ok(state) = list.key_find(parameters, "state") + let assert Ok(issuer) = list.key_find(parameters, "iss") + + #(code, state, issuer) +} + +pub fn exchange_code_for_tokens( + token_endpoint: String, + client_id: String, + redirect_uri: String, + code: String, + pkce_verifier: String, + dpop_key: gose.Key(String), + dpop_nonce: Option(String), +) -> TokenResponse { + let dpop_proof = + create_dpop_proof( + dpop_key, + "POST", + token_endpoint, + dpop_nonce, + option.None, + ) + + let form_body = + uri.query_to_string([ + #("grant_type", "authorization_code"), + #("client_id", client_id), + #("redirect_uri", redirect_uri), + #("code", code), + #("code_verifier", pkce_verifier), + ]) + + let assert Ok(token_uri) = uri.parse(token_endpoint) + let assert Ok(token_request) = request.from_uri(token_uri) + + let token_request = + request.Request( + ..token_request, + method: http.Post, + body: form_body, + ) + + let token_request = + token_request + |> request.set_header( + "content-type", + "application/x-www-form-urlencoded", + ) + |> request.set_header("dpop", dpop_proof) + + let assert Ok(token_response) = httpc.send(token_request) + + let dpop_nonce = + response.get_header(token_response, "dpop-nonce") + |> option.from_result + + + let token_response_decoder = { + use access_token <- decode.field("access_token", decode.string) + use token_type <- decode.field("token_type", decode.string) + use expires_in <- decode.field("expires_in", decode.int) + use refresh_token <- decode.optional_field( + "refresh_token", + option.None, + decode.optional(decode.string), + ) + use scope <- decode.field("scope", decode.string) + use sub <- decode.field("sub", decode.string) + + decode.success( + TokenResponse( + access_token:, + token_type:, + expires_in:, + refresh_token:, + scope:, + sub:, + dpop_nonce:, + ), + ) + } + + let assert Ok(tokens) = + json.parse(token_response.body, token_response_decoder) + tokens +} + +pub fn authenticate( + pds: String, + expected_subject: String, + client: OAuthClient, +) -> OAuthCredential { + let OAuthClient(client_id:, redirect_uri:, scope:) = client + let auth_server = get_auth_server(pds) + let metadata = get_oauth_server_metadata(auth_server) + let sessions = dict.new() + + let state = random_string() + let #(pkce_verifier, pkce_challenge) = create_pkce() + let dpop_key = create_dpop_key() + let par_response = + push_authorization_request( + metadata.pushed_authorization_request_endpoint, + client_id, + redirect_uri, + scope, + state, + pkce_challenge, + dpop_key, + ) + + let session = + OAuthSession( + state:, + issuer: metadata.issuer, + token_endpoint: metadata.token_endpoint, + pkce_verifier:, + dpop_key:, + dpop_nonce: par_response.dpop_nonce, + ) + let sessions = save_oauth_session(sessions, session) + + let callback_messages = process.new_subject() + let _callback_listener = oauth_callback.start(callback_messages) + let authorization_url = + build_authorization_url( + metadata.authorization_endpoint, + client_id, + par_response.request_uri, + ) + echo authorization_url + let _ = open_browser(authorization_url) + + let oauth_callback.Callback(code:, state: returned_state, issuer:) = + process.receive_forever(callback_messages) + let assert option.Some(session) = + get_oauth_session(sessions, returned_state) + let assert True = issuer == session.issuer + + let tokens = + exchange_code_for_tokens( + session.token_endpoint, + client_id, + redirect_uri, + code, + session.pkce_verifier, + session.dpop_key, + session.dpop_nonce, + ) + let assert True = tokens.token_type == "DPoP" + let assert True = tokens.sub == expected_subject + + let granted_scopes = string.split(tokens.scope, " ") + let assert True = list.contains(granted_scopes, "atproto") + let assert True = + list.contains(granted_scopes, "repo:sh.tangled.graph.follow") + let _sessions = dict.delete(sessions, returned_state) + + OAuthCredential( + access_token: tokens.access_token, + subject: tokens.sub, + dpop_key: session.dpop_key, + dpop_nonce: tokens.dpop_nonce, + ) +} diff --git a/src/oauth_callback.gleam b/src/oauth_callback.gleam new file mode 100644 index 0000000..778992d --- /dev/null +++ b/src/oauth_callback.gleam @@ -0,0 +1,64 @@ +import gleam/bytes_tree +import gleam/erlang/process.{type Pid, type Subject} +import gleam/http +import gleam/http/request.{type Request} +import gleam/http/response +import gleam/list +import gleam/option +import gleam/result +import gleam/uri +import mist + +pub type Callback { + Callback(code: String, state: String, issuer: String) +} + +pub fn start(callbacks: Subject(Callback)) -> Pid { + let assert Ok(server) = + fn(request) { handle_request(request, callbacks) } + |> mist.new + |> mist.bind("127.0.0.1") + |> mist.port(8080) + |> mist.start + + server.pid +} + +pub fn stop(server: Pid) -> Nil { + process.kill(server) +} + +fn handle_request( + request: Request(mist.Connection), + callbacks: Subject(Callback), +) -> response.Response(mist.ResponseData) { + case parse_callback(request) { + Ok(callback) -> { + process.send(callbacks, callback) + html_response(200, "Authorization complete. You can close this window.") + } + + Error(_) -> html_response(400, "Invalid OAuth callback.") + } +} + +fn parse_callback(request: Request(mist.Connection)) -> Result(Callback, Nil) { + case request.method, request.path, request.query { + http.Get, "/callback", option.Some(query) -> { + use parameters <- result.try(uri.parse_query(query)) + use code <- result.try(list.key_find(parameters, "code")) + use state <- result.try(list.key_find(parameters, "state")) + use issuer <- result.try(list.key_find(parameters, "iss")) + + Ok(Callback(code:, state:, issuer:)) + } + + _, _, _ -> Error(Nil) + } +} + +fn html_response(status: Int, body: String) -> response.Response(mist.ResponseData) { + response.new(status) + |> response.set_header("content-type", "text/html; charset=utf-8") + |> response.set_body(mist.Bytes(bytes_tree.from_string(body))) +} diff --git a/src/tangled.gleam b/src/tangled.gleam index de5e164..563cfd4 100644 --- a/src/tangled.gleam +++ b/src/tangled.gleam @@ -1,13 +1,18 @@ import gleam/dynamic/decode + import gleam/httpc import gleam/int import gleam/json import gleam/list import gleam/option + import possum import possum/did import possum/nsid +import atproto_records +import oauth + @external(erlang, "entwine_ffi", "now_rfc3339") fn now_rfc3339() -> String @@ -36,33 +41,28 @@ pub fn create_follow_error_message(error: CreateFollowError) -> String { pub fn create_tangled_follow( account_did: did.Did, account_pds: String, - access_token: String, + credential: oauth.OAuthCredential, subject: did.Did, -) -> Result(Nil, CreateFollowError) { - let assert Ok(collection) = nsid.parse("sh.tangled.graph.follow") - +) -> Result(oauth.OAuthCredential, CreateFollowError) { let record = [ #("$type", json.string("sh.tangled.graph.follow")), #("subject", json.string(did.to_string(subject))), #("createdAt", json.string(now_rfc3339())), ] - let request = - possum.create_record( + case + atproto_records.create_record( account_did, - pds: account_pds, - collection:, - rkey: option.None, - record:, - swap_commit: option.None, - validate: option.None, + account_pds, + "sh.tangled.graph.follow", + record, + credential, ) - |> possum.authorized(access_token) - - case httpc.send(request) { - Error(_) -> Error(RequestFailed) - Ok(response) if response.status == 200 -> Ok(Nil) - Ok(response) -> Error(UnexpectedStatus(response.status)) + { + Ok(updated_credential) -> Ok(updated_credential) + Error(atproto_records.RequestFailed) -> Error(RequestFailed) + Error(atproto_records.UnexpectedStatus(status)) -> + Error(UnexpectedStatus(status)) } } diff --git a/test/entwine_test.gleam b/test/entwine_test.gleam index 0325b5d..d37e1f6 100644 --- a/test/entwine_test.gleam +++ b/test/entwine_test.gleam @@ -1,6 +1,9 @@ import bsky import entwine +import gleam/bit_array import gleeunit +import kryptos/hash +import oauth import possum/did import tangled @@ -8,6 +11,42 @@ pub fn main() -> Nil { gleeunit.main() } +pub fn parse_callback_url_test() { + let #(code, state, issuer) = + oauth.parse_callback_url( + "http://127.0.0.1:8080/callback?code=code-123&state=state-456&iss=https%3A%2F%2Fpds.cute.haus", + ) + + assert code == "code-123" + assert state == "state-456" + assert issuer == "https://pds.cute.haus" +} + +pub fn build_authorization_url_test() { + let authorization_url = + oauth.build_authorization_url( + "https://pds.cute.haus/oauth/authorize", + "http://localhost?scope=atproto", + "urn:example:request-123", + ) + + assert authorization_url == + "https://pds.cute.haus/oauth/authorize?client_id=http%3A%2F%2Flocalhost%3Fscope%3Datproto&request_uri=urn%3Aexample%3Arequest-123" +} + +pub fn create_pkce_returns_matching_challenge_test() { + let #(verifier, challenge) = oauth.create_pkce() + let assert Ok(hash_context) = hash.new(hash.Sha256) + + let expected_challenge = + hash_context + |> hash.update(<>) + |> hash.final() + |> bit_array.base64_url_encode(False) + + assert challenge == expected_challenge +} + pub fn find_accounts_to_follow_excludes_existing_tangled_follows_test() { let alice = parse_did("did:plc:alice") let bob = parse_did("did:plc:bob")