Current section
Files
Jump to
Current section
Files
lib/ueberauth/strategy/bluesky.ex
defmodule Ueberauth.Strategy.Bluesky do
use Ueberauth.Strategy, ignores_csrf_attack: true
alias Ueberauth.Auth.Extra
alias OAuth2.Strategy.AuthCode
require Logger
# these may be variable per atproto, but they're hardcoded for the sake of this strategy
@site "https://bsky.social"
@authorize_url "#{@site}/oauth/authorize"
@token_url "#{@site}/oauth/token"
@par_url "#{@site}/oauth/par"
# this could be improved as it's a centralized point of trust that isn't bluesky itself
@plc_site "https://plc.directory/"
def handle_request!(conn) do
client = oauth2_client()
{code_verifier, code_challenge} = pkce_challenge()
conn = Plug.Conn.put_session(conn, :pkce_verifier, code_verifier)
username = conn.params["username"]
conn = Plug.Conn.put_session(conn, :user_handle, username)
state = UUID.uuid4()
conn = Plug.Conn.put_session(conn, :ueberauth_state_param, state)
auth_params = %{
"client_id" => client.client_id,
"response_type" => "code",
"redirect_uri" => client.redirect_uri,
"state" => state,
"scope" => "atproto",
"code_challenge" => code_challenge,
"code_challenge_method" => "S256",
"username" => username
}
dpop_key = generate_dpop_key()
conn = Plug.Conn.put_session(conn, :dpop_key, dpop_key)
with {:ok, request_uri} <- push_authorization_request(auth_params, dpop_key) do
redirect_url = "#{@authorize_url}?client_id=#{client.client_id}&request_uri=#{URI.encode_www_form(request_uri)}"
conn
|> Plug.Conn.put_resp_header("location", redirect_url)
|> Plug.Conn.send_resp(302, "")
|> Plug.Conn.halt()
else
{:error, reason} ->
Logger.error("PAR request failed: #{inspect(reason)}")
conn
end
end
def handle_callback!(%Plug.Conn{params: %{"code" => code, "state" => incoming_state}} = conn) do
stored_state = Plug.Conn.get_session(conn, :ueberauth_state_param)
if incoming_state != stored_state do
conn
|> Plug.Conn.put_status(:forbidden)
|> Plug.Conn.send_resp(403, "Invalid CSRF state")
|> Plug.Conn.halt()
else
client = oauth2_client()
code_verifier = Plug.Conn.get_session(conn, :pkce_verifier)
dpop_key = Plug.Conn.get_session(conn, :dpop_key)
user_handle = Plug.Conn.get_session(conn, :user_handle)
token_params = [
client_id: System.fetch_env!("BLUESKY_CLIENT_ID"),
code: code,
grant_type: "authorization_code",
redirect_uri: client.redirect_uri,
code_verifier: code_verifier,
client_assertion: generate_client_assertion(),
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
]
dpop_proof = generate_dpop_proof("POST", @token_url, dpop_key)
headers = [{"DPoP", dpop_proof}]
client = OAuth2.Client.put_headers(client, headers)
case OAuth2.Client.get_token(client, token_params) do
{:ok, token} ->
conn
|> put_private(:bluesky_token, token)
|> clear_oauth_session()
verify_handle(conn, user_handle, token)
{:error, %OAuth2.Response{headers: resp_headers} = error_resp} ->
nonce = Enum.find_value(resp_headers, fn
{"dpop-nonce", nonce} -> nonce
_ -> nil
end)
if nonce do
# Retry once with nonce
headers_with_nonce = [
{"DPoP", generate_dpop_proof("POST", @token_url, dpop_key, nonce)}
]
client = OAuth2.Client.put_headers(client, headers_with_nonce)
case OAuth2.Client.get_token(client, token_params) do
{:ok, token} ->
{:ok, decoded_token} = Jason.decode(token.token.access_token)
did = decoded_token["sub"]
conn = verify_handle(conn, user_handle, did)
auth = %Ueberauth.Auth{
provider: :bluesky,
uid: did,
info: %Ueberauth.Auth.Info{
nickname: user_handle
},
extra: %{ }
}
conn
|> put_private(:ueberauth_user_handle, user_handle)
|> put_private(:ueberauth_uid, did)
|> put_private(:ueberauth_auth, auth)
|> put_private(:bluesky_token, decoded_token)
|> clear_oauth_session()
{:error, reason} ->
Logger.error("Token exchange failed after dpop-nonce retry: #{inspect(reason)}")
conn
end
else
Logger.error("Token exchange failed: #{inspect(error_resp)}")
conn
end
{:error, reason} ->
Logger.error("Token exchange failed: #{inspect(reason)}")
conn
end
end
end
def extra(conn) do
%Extra{
raw_info: %{
token: conn.private.bluesky_token,
did: conn.private.ueberauth_uid,
handle: conn.private.ueberauth_user_handle
}
}
end
defp clear_oauth_session(conn) do
conn
|> Plug.Conn.delete_session(:ueberauth_state_param)
|> Plug.Conn.delete_session(:pkce_verifier)
|> Plug.Conn.delete_session(:dpop_key)
end
defp verify_handle(conn, handle, did) do
url = "https://plc.directory/#{did}"
case Req.get(
url: url,
redirect: false
) do
{:ok, %Req.Response{status: 200, body: body}} ->
case Jason.decode(body) do
{:ok, result_map} ->
also_known_as = Map.get(result_map, "alsoKnownAs", [])
if "at://#{handle}" in also_known_as do
conn
|> put_private(:bluesky_user_did, did)
else
set_errors!(conn, [
error("handle_verification_failed", "Handle mismatch: expected at://#{handle} in #{inspect(also_known_as)}")
])
end
{:error, reason} ->
set_errors!(conn, [error("json_decode_failed", inspect(reason))])
end
{:ok, %Req.Response{status: status, body: body}} ->
set_errors!(conn, [error("plc_fetch_failed", "HTTP #{status}: #{inspect(body)}")])
{:error, reason} ->
set_errors!(conn, [error("plc_fetch_failed", inspect(reason))])
end
end
defp oauth2_client do
OAuth2.Client.new(
strategy: AuthCode,
client_id: System.fetch_env!("BLUESKY_CLIENT_ID"),
site: @site,
redirect_uri: System.fetch_env!("BLUESKY_REDIRECT_URI"),
authorize_url: @authorize_url,
token_url: @token_url
)
end
defp generate_client_assertion do
pem = System.fetch_env!("CLIENT_PRIVATE_KEY_B64") |> Base.decode64!()
key = JOSE.JWK.from_pem(pem)
now = System.system_time(:second)
exp = now + 300
jti = UUID.uuid4()
client_id = System.fetch_env!("BLUESKY_CLIENT_ID")
claims = %{
"iss" => client_id,
"sub" => client_id,
"aud" => @token_url,
"exp" => exp,
"iat" => now,
"jti" => jti
}
{_, jwt} = JOSE.JWT.sign(key, %{"alg" => "ES256"}, claims) |> JOSE.JWS.compact()
jwt
end
defp get_dpop_nonce(headers) do
headers
|> Enum.find_value(fn
{key, value} ->
if String.downcase(key) == "dpop-nonce" do
value
else
nil
end
_ -> nil
end)
end
defp maybe_put_username(map, nil), do: map
defp maybe_put_username(map, ""), do: map
defp maybe_put_username(map, username), do: Map.put(map, "username", username)
defp generate_dpop_key do
JOSE.JWK.generate_key({:ec, :secp256r1})
end
defp generate_dpop_proof(method, url, key, nonce \\ nil) do
now = System.system_time(:second)
jti = UUID.uuid4()
uri = URI.parse(url)
htu = "#{uri.scheme}://#{uri.host}#{uri.path}"
claims = %{
"htu" => htu,
"htm" => method,
"iat" => now,
"jti" => jti
}
|> maybe_put_nonce(nonce)
header = %{
"typ" => "dpop+jwt",
"alg" => "ES256",
# "jwk" => JOSE.JWK.to_map(key) |> elem(1)
"jwk" => JOSE.JWK.to_public(key) |> JOSE.JWK.to_map() |> elem(1)
}
{_, jwt} = JOSE.JWT.sign(key, header, claims) |> JOSE.JWS.compact()
jwt
end
defp maybe_put_nonce(claims, nil), do: claims
defp maybe_put_nonce(claims, nonce), do: Map.put(claims, "nonce", nonce)
defp push_authorization_request(params, dpop_key) do
do_push_authorization_request(params, dpop_key, nil)
end
defp do_push_authorization_request(params, dpop_key, nonce) do
headers = [
{"Content-Type", "application/x-www-form-urlencoded"},
{"Accept", "application/json"},
{"DPoP", generate_dpop_proof("POST", @par_url, dpop_key, nonce)}
]
client_assertion = generate_client_assertion()
params = Map.merge(params, %{
"client_assertion" => client_assertion,
"client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
})
body = URI.encode_query(params)
case :hackney.post(@par_url, headers, body, []) do
{:ok, status, _headers, client_ref} when status in [200, 201] ->
{:ok, response_body} = :hackney.body(client_ref)
case Jason.decode(response_body) do
{:ok, %{"request_uri" => request_uri}} -> {:ok, request_uri}
error -> {:error, error}
end
{:ok, 400, resp_headers, client_ref} ->
{:ok, response_body} = :hackney.body(client_ref)
case get_dpop_nonce(resp_headers) do
nil ->
Logger.error("PAR request failed (400): #{response_body}")
{:error, :par_request_failed}
nonce ->
Logger.info("Retrying PAR request with nonce: #{nonce}")
do_push_authorization_request(params, dpop_key, nonce)
end
{:ok, status, _headers, client_ref} ->
{:ok, response_body} = :hackney.body(client_ref)
Logger.error("PAR request failed with status #{status}: #{response_body}")
{:error, :par_request_failed}
{:error, reason} ->
Logger.error("PAR request error: #{inspect(reason)}")
{:error, reason}
end
end
def generate_code_verifier(length \\ 64) do
:crypto.strong_rand_bytes(length)
|> Base.url_encode64(padding: false)
end
def generate_code_challenge(code_verifier) do
:crypto.hash(:sha256, code_verifier)
|> Base.url_encode64(padding: false)
end
def pkce_challenge() do
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)
{verifier, challenge}
end
end