dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix auth session flow

authored by

Luna and committed by tangled.org d73b8461 31afa328

+207 -22
+126 -6
appview/lib/atvouch/tangled/session.ex
··· 78 78 defp do_login(state) do 79 79 with {:ok, authorize_url} <- step1_post_login(state), 80 80 {:ok, csrf_token, pds_cookies} <- step2_get_authorize(authorize_url), 81 - {:ok, callback_url} <- step3_sign_in(authorize_url, csrf_token, pds_cookies, state), 81 + {:ok, sign_in_result} <- step3_sign_in(authorize_url, csrf_token, pds_cookies, state), 82 + {:ok, redirect_url, updated_pds_cookies} <- step3b_consent(sign_in_result, authorize_url, csrf_token, pds_cookies), 83 + {:ok, callback_url} <- step3c_follow_redirect(redirect_url, csrf_token, updated_pds_cookies), 82 84 {:ok, cookies} <- step4_oauth_callback(callback_url) do 83 85 {:ok, cookies} 84 86 end ··· 119 121 120 122 # Step 2: GET the PDS authorize URL, capture csrf-token cookie 121 123 defp step2_get_authorize(authorize_url) do 122 - case http_request(:get, authorize_url, "", []) do 124 + headers = [ 125 + {"sec-fetch-site", "cross-site"}, 126 + {"sec-fetch-mode", "navigate"}, 127 + {"sec-fetch-dest", "document"} 128 + ] 129 + 130 + case http_request(:get, authorize_url, "", headers) do 123 131 {:ok, status, resp_headers, _body} when status in [200, 302] -> 124 132 csrf_token = extract_cookie_value(resp_headers, "csrf-token") 125 133 pds_cookies = extract_set_cookies(resp_headers) ··· 147 155 body = Jason.encode!(%{ 148 156 "username" => state.handle, 149 157 "password" => state.password, 150 - "remember" => false 158 + "remember" => false, 159 + "locale" => "en" 151 160 }) 152 161 153 162 cookie_string = format_cookies(pds_cookies, csrf_token) 154 163 155 164 headers = [ 156 165 {"content-type", "application/json"}, 157 - {"cookie", cookie_string} 166 + {"cookie", cookie_string}, 167 + {"x-csrf-token", csrf_token}, 168 + {"sec-fetch-site", "same-origin"}, 169 + {"sec-fetch-mode", "same-origin"}, 170 + {"sec-fetch-dest", "empty"}, 171 + {"referer", authorize_url} 158 172 ] 159 173 160 174 case http_request(:post, sign_in_url, body, headers) do 161 175 {:ok, 200, _headers, resp_body} -> 162 176 case Jason.decode(resp_body) do 163 177 {:ok, %{"url" => url}} -> 164 - {:ok, url} 178 + {:ok, {:url, url}} 179 + 180 + {:ok, %{"ephemeralToken" => token, "account" => %{"sub" => sub}}} -> 181 + {:ok, {:ephemeral, token, sub}} 182 + 183 + {:ok, %{"ephemeralToken" => token}} -> 184 + {:ok, {:ephemeral, token, nil}} 165 185 166 186 {:ok, %{"consentRequired" => true}} -> 167 - # TODO: handle consent flow if needed 168 187 {:error, :consent_required} 169 188 170 189 _ -> ··· 179 198 end 180 199 end 181 200 201 + # Step 3b: If sign-in returned an ephemeral token, POST it to the accept endpoint 202 + # to get the callback URL. If we already have a URL, pass it through. 203 + defp step3b_consent({:url, url}, _authorize_url, _csrf_token, pds_cookies) do 204 + {:ok, url, pds_cookies} 205 + end 206 + 207 + defp step3b_consent({:ephemeral, token, sub}, authorize_url, csrf_token, pds_cookies) do 208 + uri = URI.parse(authorize_url) 209 + consent_url = "#{uri.scheme}://#{uri.authority}/@atproto/oauth-provider/~api/consent" 210 + 211 + body = Jason.encode!(%{"sub" => sub}) 212 + cookie_string = format_cookies(pds_cookies, csrf_token) 213 + 214 + headers = [ 215 + {"content-type", "application/json"}, 216 + {"cookie", cookie_string}, 217 + {"authorization", "Bearer #{token}"}, 218 + {"x-csrf-token", csrf_token}, 219 + {"sec-fetch-site", "same-origin"}, 220 + {"sec-fetch-mode", "same-origin"}, 221 + {"sec-fetch-dest", "empty"}, 222 + {"referer", authorize_url} 223 + ] 224 + 225 + case http_request(:post, consent_url, body, headers) do 226 + {:ok, 200, resp_headers, resp_body} -> 227 + case Jason.decode(resp_body) do 228 + {:ok, %{"url" => url}} -> 229 + updated_cookies = merge_cookies(pds_cookies, extract_set_cookies(resp_headers)) 230 + {:ok, url, updated_cookies} 231 + 232 + _ -> 233 + {:error, :unexpected_consent_response} 234 + end 235 + 236 + {:ok, status, _headers, resp_body} -> 237 + Logger.warning("Consent failed with #{status}: #{inspect(String.slice(resp_body, 0, 200))}") 238 + {:error, {:consent_failed, status}} 239 + 240 + {:error, reason} -> 241 + {:error, {:consent_request_failed, reason}} 242 + end 243 + end 244 + 245 + # Step 3c: Follow the PDS redirect URL (e.g. /oauth/authorize/redirect?...) 246 + # which 302s/303s to the tangled callback URL. 247 + # For the old flow where consent isn't needed, the URL is already the callback. 248 + # The consent response URL is a PDS redirect endpoint like: 249 + # https://pds.example/oauth/authorize/redirect?redirect_mode=query&redirect_uri=https://app/callback&iss=...&state=...&code=... 250 + # We extract the callback URL with its query params directly rather than 251 + # following the redirect, since the PDS redirect endpoint enforces strict 252 + # browser security headers that are difficult to replicate server-side. 253 + defp step3c_follow_redirect(url, _csrf_token, _pds_cookies) do 254 + uri = URI.parse(url) 255 + 256 + # If this is already a tangled callback URL, pass through 257 + if String.contains?(uri.path || "", "/oauth/callback") do 258 + {:ok, url} 259 + else 260 + # Parse the redirect URL's query params to construct the callback URL 261 + params = URI.decode_query(uri.query || "") 262 + redirect_uri = params["redirect_uri"] 263 + 264 + if redirect_uri do 265 + # Build the callback URL with the remaining params (iss, state, code) 266 + callback_params = 267 + params 268 + |> Map.drop(["redirect_mode", "redirect_uri"]) 269 + |> URI.encode_query() 270 + 271 + callback_url = "#{redirect_uri}?#{callback_params}" 272 + {:ok, callback_url} 273 + else 274 + {:error, :no_redirect_uri_in_consent_url} 275 + end 276 + end 277 + end 278 + 182 279 # Step 4: Follow the callback URL to Tangled to get session cookies 183 280 defp step4_oauth_callback(callback_url) do 184 281 case http_request(:get, callback_url, "", []) do ··· 260 357 defp format_cookies(pds_cookies, csrf_token) do 261 358 cookies = pds_cookies ++ ["csrf-token=#{csrf_token}"] 262 359 Enum.join(cookies, "; ") 360 + end 361 + 362 + defp merge_cookies(existing, new_set_cookies) do 363 + new_map = 364 + new_set_cookies 365 + |> Enum.map(fn cookie -> 366 + [name | _] = String.split(cookie, "=", parts: 2) 367 + {name, cookie} 368 + end) 369 + |> Map.new() 370 + 371 + existing_names = existing |> Enum.map(fn c -> String.split(c, "=", parts: 2) |> List.first() end) |> MapSet.new() 372 + 373 + updated = Enum.map(existing, fn cookie -> 374 + [name | _] = String.split(cookie, "=", parts: 2) 375 + Map.get(new_map, name, cookie) 376 + end) 377 + 378 + new_only = Enum.reject(new_set_cookies, fn c -> 379 + MapSet.member?(existing_names, String.split(c, "=", parts: 2) |> List.first()) 380 + end) 381 + 382 + updated ++ new_only 263 383 end 264 384 end
+81 -16
appview/test/support/fake_pds_server.ex
··· 10 10 plug(:dispatch) 11 11 12 12 # OAuth authorize page - sets CSRF cookie and returns 200 13 + # After consent is granted, returns 302 redirect to callback 14 + # Enforces sec-fetch-site header like real PDS does 13 15 get "/@atproto/oauth-provider/authorize" do 14 16 test_pid = conn.private[:test_pid] 15 17 send(test_pid, {:pds_authorize, conn.query_string}) 16 18 17 - conn 18 - |> put_resp_header("set-cookie", "csrf-token=test-csrf-token; Path=/; HttpOnly") 19 - |> put_resp_content_type("text/html") 20 - |> send_resp(200, "<html><body>Authorize</body></html>") 19 + sec_fetch_site = Plug.Conn.get_req_header(conn, "sec-fetch-site") |> List.first() 20 + 21 + if is_nil(sec_fetch_site) do 22 + conn 23 + |> put_resp_content_type("application/json") 24 + |> send_resp(400, Jason.encode!(%{ 25 + "error" => "invalid_request", 26 + "error_description" => "Missing sec-fetch-site header" 27 + })) 28 + else 29 + conn 30 + |> put_resp_header("set-cookie", "csrf-token=test-csrf-token; Path=/; HttpOnly") 31 + |> put_resp_content_type("text/html") 32 + |> send_resp(200, "<html><body>Authorize</body></html>") 33 + end 21 34 end 22 35 23 36 # Sign-in API endpoint 37 + # Enforces referer header like real PDS does 24 38 post "/@atproto/oauth-provider/~api/sign-in" do 25 39 test_pid = conn.private[:test_pid] 26 40 callback_url = conn.private[:callback_url] ··· 29 43 30 44 send(test_pid, {:pds_sign_in, params}) 31 45 46 + referer = Plug.Conn.get_req_header(conn, "referer") |> List.first() 47 + csrf_header = Plug.Conn.get_req_header(conn, "x-csrf-token") |> List.first() 32 48 valid_username = conn.private[:expected_username] 33 49 valid_password = conn.private[:expected_password] 34 50 35 - if params["username"] == valid_username and params["password"] == valid_password do 36 - conn 37 - |> put_resp_content_type("application/json") 38 - |> send_resp(200, Jason.encode!(%{ 39 - "consentRequired" => false, 40 - "url" => callback_url 41 - })) 42 - else 43 - conn 44 - |> put_resp_content_type("application/json") 45 - |> send_resp(401, Jason.encode!(%{"error" => "invalid_credentials"})) 51 + cond do 52 + is_nil(csrf_header) -> 53 + conn 54 + |> put_resp_content_type("application/json") 55 + |> send_resp(400, Jason.encode!(%{ 56 + "error" => "invalid_request", 57 + "error_description" => "Missing CSRF header" 58 + })) 59 + 60 + is_nil(referer) -> 61 + conn 62 + |> put_resp_content_type("application/json") 63 + |> send_resp(400, Jason.encode!(%{ 64 + "error" => "invalid_request", 65 + "error_description" => "Invalid referrer undefined" 66 + })) 67 + 68 + params["username"] == valid_username and params["password"] == valid_password -> 69 + conn 70 + |> put_resp_content_type("application/json") 71 + |> send_resp(200, Jason.encode!(%{ 72 + "consentRequired" => false, 73 + "ephemeralToken" => "test-ephemeral-token", 74 + "account" => %{"sub" => "did:plc:test"} 75 + })) 76 + 77 + true -> 78 + conn 79 + |> put_resp_content_type("application/json") 80 + |> send_resp(401, Jason.encode!(%{"error" => "invalid_credentials"})) 81 + end 82 + end 83 + 84 + # Consent endpoint - approves the authorization after sign-in 85 + post "/@atproto/oauth-provider/~api/consent" do 86 + test_pid = conn.private[:test_pid] 87 + callback_url = conn.private[:callback_url] 88 + {:ok, body, conn} = Plug.Conn.read_body(conn) 89 + params = Jason.decode!(body) 90 + 91 + csrf_header = Plug.Conn.get_req_header(conn, "x-csrf-token") |> List.first() 92 + referer = Plug.Conn.get_req_header(conn, "referer") |> List.first() 93 + auth_header = Plug.Conn.get_req_header(conn, "authorization") |> List.first() 94 + 95 + send(test_pid, {:pds_consent, params}) 96 + 97 + cond do 98 + is_nil(csrf_header) or is_nil(referer) or is_nil(auth_header) -> 99 + conn 100 + |> put_resp_content_type("application/json") 101 + |> send_resp(400, Jason.encode!(%{"error" => "invalid_request"})) 102 + 103 + true -> 104 + conn 105 + |> put_resp_content_type("application/json") 106 + |> send_resp(200, Jason.encode!(%{"url" => callback_url})) 46 107 end 47 108 end 48 109 ··· 62 123 |> Plug.Conn.put_private(:callback_url, opts[:callback_url]) 63 124 |> Plug.Conn.put_private(:expected_username, opts[:expected_username]) 64 125 |> Plug.Conn.put_private(:expected_password, opts[:expected_password]) 126 + |> Plug.Conn.put_private(:state_agent_pds, opts[:state_agent_pds]) 65 127 |> Router.call(Router.init([])) 66 128 end 67 129 end ··· 70 132 callback_url = Keyword.get(opts, :callback_url, "http://localhost:0/oauth/callback") 71 133 expected_username = Keyword.get(opts, :expected_username, "bot.test") 72 134 expected_password = Keyword.get(opts, :expected_password, "test-password") 135 + 136 + {:ok, state_agent} = Agent.start_link(fn -> %{} end) 73 137 74 138 {:ok, server_pid} = 75 139 Bandit.start_link( ··· 77 141 test_pid: test_pid, 78 142 callback_url: callback_url, 79 143 expected_username: expected_username, 80 - expected_password: expected_password}, 144 + expected_password: expected_password, 145 + state_agent_pds: state_agent}, 81 146 port: 0, 82 147 ip: {127, 0, 0, 1} 83 148 )