Lexicon-driven ATProto AppView in Gleam — port of HappyView
1-module(gleamview_ffi).
2-export([
3 open_browser/1,
4 oauth_can_bind/1,
5 oauth_wait_callback/2,
6 dpop_generate_keypair/0,
7 dpop_sign_jwt/3,
8 base64url_encode/1,
9 base64url_decode/1,
10 random_bytes/1,
11 sha256/1
12]).
13
14
15reason_bin(Reason) ->
16 unicode:characters_to_binary(io_lib:format("~p", [Reason])).
17
18%% ---------------------------------------------------------------------------
19%% OAuth / DPoP helpers
20%% ---------------------------------------------------------------------------
21
22open_browser(Url) when is_binary(Url) ->
23 Target = binary_to_list(Url),
24 Cmds =
25 case os:type() of
26 {unix, darwin} -> [{"open", [Target]}];
27 {win32, _} -> [{"cmd", ["/c", "start", "", Target]}];
28 _ -> [{"xdg-open", [Target]}]
29 end,
30 open_browser_try(Cmds).
31
32open_browser_try([]) ->
33 {error, <<"no browser opener available">>};
34open_browser_try([{Cmd, Args} | Rest]) ->
35 case os:find_executable(Cmd) of
36 false ->
37 open_browser_try(Rest);
38 Path ->
39 try
40 Port = open_port(
41 {spawn_executable, Path},
42 [{args, Args}, hide, binary, exit_status]
43 ),
44 spawn(fun() -> drain_port(Port) end),
45 {ok, nil}
46 catch
47 _:_ -> open_browser_try(Rest)
48 end
49 end.
50
51drain_port(Port) ->
52 receive
53 {Port, {exit_status, _}} -> ok;
54 {Port, _} -> drain_port(Port)
55 after 5000 ->
56 catch port_close(Port),
57 ok
58 end.
59
60%% Probe whether we can bind 127.0.0.1:Port (closes immediately).
61oauth_can_bind(Port) when is_integer(Port) ->
62 case
63 gen_tcp:listen(Port, [
64 binary,
65 {packet, raw},
66 {active, false},
67 {reuseaddr, true},
68 {ip, {127, 0, 0, 1}},
69 {backlog, 1}
70 ])
71 of
72 {ok, Listen} ->
73 gen_tcp:close(Listen),
74 true;
75 {error, _} ->
76 false
77 end.
78
79%% Listen on 127.0.0.1:Port, wait for one HTTP request, return query string.
80%% TimeoutMs is the overall wait budget.
81oauth_wait_callback(Port, TimeoutMs) when is_integer(Port), is_integer(TimeoutMs) ->
82 case
83 gen_tcp:listen(Port, [
84 binary,
85 {packet, raw},
86 {active, false},
87 {reuseaddr, true},
88 {ip, {127, 0, 0, 1}},
89 {backlog, 1}
90 ])
91 of
92 {error, Reason} ->
93 {error, reason_bin(Reason)};
94 {ok, Listen} ->
95 try
96 case gen_tcp:accept(Listen, TimeoutMs) of
97 {error, Reason} ->
98 {error, reason_bin(Reason)};
99 {ok, Sock} ->
100 case recv_http_request(Sock, <<>>, TimeoutMs) of
101 {error, Reason} ->
102 gen_tcp:close(Sock),
103 {error, Reason};
104 {ok, PathQuery} ->
105 Body =
106 <<"HTTP/1.1 200 OK\r\n",
107 "content-type: text/plain; charset=utf-8\r\n",
108 "connection: close\r\n",
109 "content-length: 48\r\n",
110 "\r\n",
111 "Authentication complete. You can close this window.">>,
112 _ = gen_tcp:send(Sock, Body),
113 gen_tcp:close(Sock),
114 {ok, PathQuery}
115 end
116 end
117 after
118 gen_tcp:close(Listen)
119 end
120 end.
121
122recv_http_request(Sock, Acc, TimeoutMs) ->
123 case gen_tcp:recv(Sock, 0, min(TimeoutMs, 30_000)) of
124 {error, Reason} ->
125 {error, reason_bin(Reason)};
126 {ok, Data} ->
127 Buf = <<Acc/binary, Data/binary>>,
128 case binary:match(Buf, <<"\r\n\r\n">>) of
129 nomatch when byte_size(Buf) > 65536 ->
130 {error, <<"HTTP request too large">>};
131 nomatch ->
132 recv_http_request(Sock, Buf, TimeoutMs);
133 {Pos, _} ->
134 Header = binary:part(Buf, 0, Pos),
135 case parse_request_line(Header) of
136 {error, _} = E -> E;
137 {ok, PathQuery} -> {ok, PathQuery}
138 end
139 end
140 end.
141
142parse_request_line(Header) ->
143 case binary:split(Header, <<"\r\n">>) of
144 [Line | _] ->
145 case binary:split(Line, <<" ">>, [global]) of
146 [_Method, PathQuery | _] -> {ok, PathQuery};
147 _ -> {error, <<"malformed request line">>}
148 end;
149 _ ->
150 {error, <<"empty request">>}
151 end.
152
153%% Generate P-256 keypair for DPoP. Returns {ok, {D, X, Y}} as base64url binaries.
154dpop_generate_keypair() ->
155 {Pub, Priv} = crypto:generate_key(ecdh, secp256r1),
156 <<4, X:32/binary, Y:32/binary>> = Pub,
157 D =
158 case Priv of
159 <<D0:32/binary>> -> D0;
160 _ when is_binary(Priv), byte_size(Priv) =< 32 ->
161 pad32(Priv);
162 _ ->
163 error({bad_priv, Priv})
164 end,
165 {ok, {base64url_encode(D), base64url_encode(X), base64url_encode(Y)}}.
166
167%% Sign JWT signing-input (header.payload) with ES256 using private key D (base64url).
168%% Public X,Y also base64url — used only for header jwk construction by Gleam.
169%% Returns base64url signature (R||S, 64 bytes).
170dpop_sign_jwt(SigningInput, DB64, _XYIgnored) when is_binary(SigningInput), is_binary(DB64) ->
171 try
172 D = base64url_decode_strict(DB64),
173 D32 = pad32(D),
174 Der = crypto:sign(ecdsa, sha256, SigningInput, [D32, secp256r1]),
175 Raw = der_ecdsa_to_raw(Der),
176 {ok, base64url_encode(Raw)}
177 catch
178 C:E:S ->
179 {error, reason_bin({C, E, S})}
180 end.
181
182base64url_encode(Bin) when is_binary(Bin) ->
183 Enc = base64:encode(Bin, #{mode => urlsafe, padding => false}),
184 case is_binary(Enc) of
185 true -> Enc;
186 false -> unicode:characters_to_binary(Enc)
187 end.
188
189base64url_decode(Bin) when is_binary(Bin) ->
190 try
191 {ok, base64url_decode_strict(Bin)}
192 catch
193 _:_ -> {error, nil}
194 end.
195
196base64url_decode_strict(Bin) ->
197 %% OTP base64 urlsafe may need padding restored
198 Padded = pad_b64(Bin),
199 Decoded = base64:decode(Padded, #{mode => urlsafe}),
200 case Decoded of
201 B when is_binary(B) -> B;
202 {ok, B} when is_binary(B) -> B;
203 Other -> error({bad_base64, Other})
204 end.
205
206pad_b64(Bin) ->
207 case byte_size(Bin) rem 4 of
208 0 -> Bin;
209 2 -> <<Bin/binary, "==">>;
210 3 -> <<Bin/binary, "=">>;
211 1 -> <<Bin/binary, "===">>
212 end.
213
214random_bytes(N) when is_integer(N), N > 0 ->
215 crypto:strong_rand_bytes(N).
216
217sha256(Bin) when is_binary(Bin) ->
218 crypto:hash(sha256, Bin).
219
220pad32(Bin) when byte_size(Bin) =:= 32 ->
221 Bin;
222pad32(Bin) when byte_size(Bin) < 32 ->
223 Pad = 32 - byte_size(Bin),
224 <<0:(Pad * 8), Bin/binary>>;
225pad32(Bin) when byte_size(Bin) > 32 ->
226 binary:part(Bin, byte_size(Bin) - 32, 32).
227
228%% Convert DER ECDSA signature to IEEE P1363 R||S (32+32 bytes).
229der_ecdsa_to_raw(<<16#30, _Len, Rest0/binary>>) ->
230 {R, Rest1} = der_parse_int(Rest0),
231 {S, _} = der_parse_int(Rest1),
232 <<(pad32(R))/binary, (pad32(S))/binary>>;
233der_ecdsa_to_raw(Other) ->
234 error({bad_der_sig, Other}).
235
236der_parse_int(<<16#02, Len, Rest/binary>>) when Len > 0, byte_size(Rest) >= Len ->
237 <<Int:Len/binary, Tail/binary>> = Rest,
238 %% INTEGER may have a leading 0x00 for positive values with high bit set
239 Clean = strip_der_int_padding(Int),
240 {Clean, Tail};
241der_parse_int(Other) ->
242 error({bad_der_int, Other}).
243
244strip_der_int_padding(<<0, RestInt/binary>>) when byte_size(RestInt) > 0 ->
245 case binary:first(RestInt) band 16#80 of
246 0 -> <<0, RestInt/binary>>;
247 _ -> RestInt
248 end;
249strip_der_int_padding(Int) ->
250 Int.