Skip to content

Instantly share code, notes, and snippets.

@marckohlbrugge
Last active November 16, 2024 01:25
Show Gist options
  • Save marckohlbrugge/6167a3689c23d6cbbec811cf65033a36 to your computer and use it in GitHub Desktop.
Save marckohlbrugge/6167a3689c23d6cbbec811cf65033a36 to your computer and use it in GitHub Desktop.
work in progress implementation of `omniauth-bluesky`
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
{
"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
}
# 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