Last active
November 16, 2024 01:25
-
-
Save marckohlbrugge/6167a3689c23d6cbbec811cf65033a36 to your computer and use it in GitHub Desktop.
work in progress implementation of `omniauth-bluesky`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'omniauth-oauth2' | |
require 'openssl' | |
require 'jwt' | |
require 'securerandom' | |
module OmniAuth | |
module Strategies | |
class Bluesky < OmniAuth::Strategies::OAuth2 | |
option :name, 'bluesky' | |
option :client_options, { | |
site: 'https://bsky.social', | |
authorize_url: '/oauth/authorize', | |
token_url: '/oauth/token', | |
par_url: '/oauth/par' | |
} | |
def generate_dpop_key | |
key = OpenSSL::PKey::EC.generate('prime256v1') | |
pub_point = key.public_key.to_bn.to_s(2) | |
# Extract x and y coordinates (skip the 0x04 prefix) | |
x = pub_point[2..65] | |
y = pub_point[66..129] | |
{ | |
key: key, | |
jwk: { | |
kty: 'EC', | |
crv: 'P-256', | |
x: Base64.urlsafe_encode64([x].pack('H*'), padding: false), | |
y: Base64.urlsafe_encode64([y].pack('H*'), padding: false) | |
} | |
} | |
end | |
def generate_dpop_proof(key, jwk, htu:, htm:) | |
header = { | |
typ: 'dpop+jwt', | |
alg: 'ES256', | |
jwk: jwk | |
} | |
payload = { | |
htu: htu, | |
htm: htm, | |
iat: Time.now.to_i, | |
jti: SecureRandom.uuid | |
} | |
JWT.encode(payload, key, 'ES256', header) | |
end | |
def request_phase | |
# Generate PKCE values | |
verifier = SecureRandom.urlsafe_base64(72) | |
challenge = Base64.urlsafe_encode64( | |
Digest::SHA256.digest(verifier), | |
padding: false | |
) | |
session['omniauth.pkce.verifier'] = verifier | |
# Generate DPoP key and store it | |
key = OpenSSL::PKey::EC.generate('prime256v1') | |
pub_key_bn = key.public_key.to_bn | |
# The public key is in uncompressed format: 0x04 || x || y | |
pub_key_hex = pub_key_bn.to_s(16) | |
x_hex = pub_key_hex[2, 64] | |
y_hex = pub_key_hex[66, 64] | |
jwk = { | |
kty: 'EC', | |
crv: 'P-256', | |
x: Base64.urlsafe_encode64([x_hex].pack('H*'), padding: false), | |
y: Base64.urlsafe_encode64([y_hex].pack('H*'), padding: false) | |
} | |
session['omniauth.dpop.key'] = key.to_pem | |
# First make a request to get the DPoP nonce | |
uri = URI("#{options.client_options.site}#{options.client_options.par_url}") | |
initial_response = Net::HTTP.post( | |
uri, | |
'', | |
{ 'Content-Type' => 'application/x-www-form-urlencoded' } | |
) | |
nonce = initial_response['DPoP-Nonce'] | |
# Create PAR params | |
redirect_uri = callback_url | |
session['omniauth.redirect_uri'] = redirect_uri # Store it for later | |
Rails.logger.info "PAR phase redirect_uri: #{redirect_uri}" | |
par_params = { | |
client_id: options.client_id, | |
code_challenge: challenge, | |
code_challenge_method: 'S256', | |
redirect_uri: redirect_uri, | |
response_type: 'code', | |
scope: 'atproto transition:generic', | |
state: SecureRandom.hex | |
} | |
# Generate DPoP proof with nonce | |
header = { | |
typ: 'dpop+jwt', | |
alg: 'ES256', | |
jwk: jwk | |
} | |
payload = { | |
htu: "#{options.client_options.site}#{options.client_options.par_url}", | |
htm: 'POST', | |
iat: Time.now.to_i, | |
jti: SecureRandom.uuid, | |
nonce: nonce | |
} | |
proof = JWT.encode(payload, key, 'ES256', header) | |
# Make PAR request with DPoP | |
response = Net::HTTP.post( | |
uri, | |
URI.encode_www_form(par_params), | |
{ | |
'Content-Type' => 'application/x-www-form-urlencoded', | |
'DPoP' => proof | |
} | |
) | |
par_response = JSON.parse(response.body) | |
session['omniauth.state'] = par_params[:state] | |
# Build the redirect URL more carefully | |
authorize_url = "#{options.client_options.site}#{options.client_options.authorize_url}" | |
query_params = { | |
client_id: options.client_id, | |
request_uri: par_response['request_uri'] | |
} | |
redirect_url = "#{authorize_url}?#{URI.encode_www_form(query_params)}" | |
redirect redirect_url | |
end | |
def client | |
# Log the callback_url we're trying to use | |
original_redirect_uri = session['omniauth.redirect_uri'] # From request_phase | |
token_redirect_uri = callback_url | |
Rails.logger.info "Original redirect_uri: #{original_redirect_uri}" | |
Rails.logger.info "Token redirect_uri: #{token_redirect_uri}" | |
# First get the DPoP nonce | |
uri = URI("#{options.client_options.site}#{options.client_options.token_url}") | |
initial_response = Net::HTTP.post( | |
uri, | |
'', | |
{ 'Content-Type' => 'application/x-www-form-urlencoded' } | |
) | |
nonce = initial_response['DPoP-Nonce'] | |
# Generate DPoP proof with nonce | |
key = OpenSSL::PKey::EC.new(session['omniauth.dpop.key']) | |
pub_key_bn = key.public_key.to_bn | |
pub_key_hex = pub_key_bn.to_s(16) | |
x_hex = pub_key_hex[2, 64] | |
y_hex = pub_key_hex[66, 64] | |
jwk = { | |
kty: 'EC', | |
crv: 'P-256', | |
x: Base64.urlsafe_encode64([x_hex].pack('H*'), padding: false), | |
y: Base64.urlsafe_encode64([y_hex].pack('H*'), padding: false) | |
} | |
proof = JWT.encode({ | |
htu: "#{options.client_options.site}#{options.client_options.token_url}", | |
htm: 'POST', | |
iat: Time.now.to_i, | |
jti: SecureRandom.uuid, | |
nonce: nonce | |
}, key, 'ES256', { | |
typ: 'dpop+jwt', | |
alg: 'ES256', | |
jwk: jwk | |
}) | |
::OAuth2::Client.new( | |
options.client_id, | |
nil, | |
deep_symbolize(options.client_options).merge( | |
auth_scheme: :request_body, | |
connection_opts: { | |
headers: { | |
'DPoP' => proof | |
} | |
}, | |
params: { | |
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', | |
client_assertion: generate_client_assertion, | |
redirect_uri: session['omniauth.redirect_uri'] | |
} | |
) | |
) | |
end | |
private | |
def generate_client_assertion | |
key = OpenSSL::PKey::EC.generate('prime256v1') | |
payload = { | |
iss: options.client_id, | |
sub: options.client_id, | |
aud: "#{options.client_options.site}#{options.client_options.token_url}", | |
jti: SecureRandom.uuid, | |
exp: Time.now.to_i + 300, | |
iat: Time.now.to_i | |
} | |
JWT.encode(payload, key, 'ES256') | |
end | |
def callback_url | |
# Get the full current URL | |
full_host + script_name + callback_path | |
end | |
uid { raw_info['did'] } | |
info do | |
{ | |
did: raw_info['did'], | |
handle: raw_info['handle'], | |
display_name: raw_info['displayName'], | |
description: raw_info['description'], | |
avatar: raw_info['avatar'], | |
dpop_key: session['omniauth.dpop.key'] | |
} | |
end | |
def raw_info | |
@raw_info ||= begin | |
did = access_token.params['sub'] | |
HTTP.get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile", params: { | |
actor: did | |
}).parse | |
end | |
end | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"redirect_uris": [ | |
"https://local.blueskycounter.com/auth/bluesky/callback" | |
], | |
"response_types": [ | |
"code" | |
], | |
"grant_types": [ | |
"authorization_code", | |
"refresh_token" | |
], | |
"scope": "atproto transition:generic", | |
"token_endpoint_auth_method": "none", | |
"application_type": "web", | |
"client_id": "https://local.blueskycounter.com/client-metadata.json", | |
"client_name": "BlueskyCounter (Dev)", | |
"client_uri": "https://local.blueskycounter.com", | |
"dpop_bound_access_tokens": true | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# config/initializers/omniauth.rb | |
require 'omniauth/strategies/bluesky' | |
Rails.application.config.middleware.use OmniAuth::Builder do | |
provider :bluesky, | |
"https://local.blueskycounter.com/client-metadata.json", | |
nil, | |
scope: "atproto transition:generic", | |
redirect_uri: "https://local.blueskycounter.com/auth/bluesky/callback", | |
pkce: true | |
end | |
OmniAuth.config.allowed_request_methods = [:post, :get] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment