omniauth-atproto 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +77 -0
- data/lib/omniauth/strategies/atproto.rb +86 -0
- data/lib/omniauth-atproto/key_manager.rb +77 -0
- data/lib/omniauth-atproto/metadata_generator.rb +24 -0
- data/lib/omniauth-atproto/version.rb +5 -0
- data/lib/omniauth-atproto.rb +10 -0
- metadata +150 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5e2f34cc5f324628d5f73659d4cd3ea11cbfbcdc5420cefaa78908c928008d7a
|
4
|
+
data.tar.gz: 1e349da8447a8d95b29a8f0c8965d13c1cdf5525e84ecdb02a2bf69b253de82e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d71697e0c667218a143ad87f2bf30098e6766365cb6bef90709ffdb004bffdff3edd000c332c8124279c3dd63c4cc9a47748e3658c23124ee629a41e9eddbd4d
|
7
|
+
data.tar.gz: fafbab85910a7b064e9f4ef118bc9c5f0df5d900a4ca7b8c9c01286a76ccc4c88c0bbb1ceb96d174d3db5d71de3acbdb396d3ee62dbe57a9c90d4b193d941dc4
|
data/README.md
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
# Omniauth-atproto
|
4
|
+
|
5
|
+
An omniauth strategy for Atproto (bluesky)
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'omniauth-atproto'
|
13
|
+
```
|
14
|
+
|
15
|
+
|
16
|
+
## Usage
|
17
|
+
|
18
|
+
You can cnfigure it :
|
19
|
+
```ruby
|
20
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
21
|
+
provider(:atproto,
|
22
|
+
"#{Rails.application.config.app_url}/oauth/client-metadata.json",
|
23
|
+
nil,
|
24
|
+
client_options: {
|
25
|
+
site: "https://bsky.social",
|
26
|
+
authorize_url: "https://bsky.social/oauth/authorize",
|
27
|
+
token_url: "https://bsky.social/oauth/token"
|
28
|
+
},
|
29
|
+
scope: "atproto transition:generic",
|
30
|
+
private_key: OmniAuth::Atproto::KeyManager.current_private_key,
|
31
|
+
client_jwk: OmniAuth::Atproto::KeyManager.current_jwk)
|
32
|
+
end
|
33
|
+
```
|
34
|
+
You will have to generate keys and the oauth/client-metadata.json document (a generator should come soon)
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
#lib/tasks/atproto.rake
|
38
|
+
:atproto do
|
39
|
+
desc "Generate new AtProto key pair and rotate keys"
|
40
|
+
task rotate_keys: :environment do
|
41
|
+
OmniAuth::Atproto::KeyManager.rotate_keys
|
42
|
+
puts "New key generated and saved. Old key backed up if it existed."
|
43
|
+
Rake::Task["atproto:generate_metadata"].invoke
|
44
|
+
end
|
45
|
+
|
46
|
+
desc "Generate client metadata JSON file"
|
47
|
+
task generate_metadata: :environment do
|
48
|
+
metadata = {
|
49
|
+
client_id: "#{Rails.application.config.app_url}/oauth/client-metadata.json",
|
50
|
+
application_type: "web",
|
51
|
+
client_name: Rails.application.class.module_parent_name,
|
52
|
+
client_uri: Rails.application.config.app_url,
|
53
|
+
dpop_bound_access_tokens: true,
|
54
|
+
grant_types: %w[authorization_code refresh_token],
|
55
|
+
redirect_uris: [ "#{Rails.application.config.app_url}/auth/atproto/callback" ],
|
56
|
+
response_types: [ "code" ],
|
57
|
+
scope: "atproto transition:generic",
|
58
|
+
token_endpoint_auth_method: "private_key_jwt",
|
59
|
+
token_endpoint_auth_signing_alg: "ES256",
|
60
|
+
jwks: {
|
61
|
+
keys: [ OmniAuth::Atproto::KeyManager.current_jwk ]
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
oauth_dir = Rails.root.join("public", "oauth")
|
66
|
+
FileUtils.mkdir_p(oauth_dir) unless Dir.exist?(oauth_dir)
|
67
|
+
metadata_path = oauth_dir.join("client-metadata.json")
|
68
|
+
File.write(metadata_path, JSON.pretty_generate(metadata))
|
69
|
+
puts "Generated metadata file at #{metadata_path}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
```
|
73
|
+
Then you can
|
74
|
+
```bash
|
75
|
+
rails atproto:generate_metadata
|
76
|
+
```
|
77
|
+
The values from the metadata endpoint should correspond to those you gave as option for the strategy (that's why a generator would be very handy)
|
@@ -0,0 +1,86 @@
|
|
1
|
+
require 'omniauth-oauth2'
|
2
|
+
require 'json'
|
3
|
+
require 'net/http'
|
4
|
+
require 'atproto_client'
|
5
|
+
|
6
|
+
module OmniAuth
|
7
|
+
module Strategies
|
8
|
+
class Atproto < OmniAuth::Strategies::OAuth2
|
9
|
+
def initialize(app, *args)
|
10
|
+
super
|
11
|
+
@dpop_handler = AtProto::DpopHandler.new(options.private_key)
|
12
|
+
end
|
13
|
+
|
14
|
+
option :scope, 'atproto'
|
15
|
+
option :pkce, true
|
16
|
+
option :token_params, {
|
17
|
+
test: true
|
18
|
+
}
|
19
|
+
|
20
|
+
info do
|
21
|
+
{
|
22
|
+
did: @access_token.params['sub'],
|
23
|
+
pds_host: options.client_options.site
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def build_access_token
|
30
|
+
new_token_params = token_params.merge(
|
31
|
+
{
|
32
|
+
grant_type: 'authorization_code',
|
33
|
+
redirect_uri: full_host + callback_path,
|
34
|
+
code: request.params['code'],
|
35
|
+
client_id: options.client_id,
|
36
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
37
|
+
client_assertion: generate_client_assertion
|
38
|
+
}
|
39
|
+
)
|
40
|
+
response = @dpop_handler.make_request(
|
41
|
+
client.token_url,
|
42
|
+
:post,
|
43
|
+
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' },
|
44
|
+
body: new_token_params
|
45
|
+
)
|
46
|
+
|
47
|
+
::OAuth2::AccessToken.from_hash(client, response)
|
48
|
+
end
|
49
|
+
|
50
|
+
def generate_client_assertion
|
51
|
+
# Should return a JWT signed with the private key corresponding to the one in client-metadata.json
|
52
|
+
|
53
|
+
raise 'Client ID is required' unless options.client_id
|
54
|
+
raise 'Client JWK is required' unless options.client_jwk
|
55
|
+
|
56
|
+
private_key = if options.private_key.is_a?(String)
|
57
|
+
OpenSSL::PKey::EC.new(options.private_key)
|
58
|
+
elsif options.private_key.is_a?(OpenSSL::PKey::EC)
|
59
|
+
options.private_key
|
60
|
+
else
|
61
|
+
raise 'Invalid private_key format'
|
62
|
+
end
|
63
|
+
|
64
|
+
jwt_payload = {
|
65
|
+
iss: options.client_id,
|
66
|
+
sub: options.client_id,
|
67
|
+
aud: options.client_options.site,
|
68
|
+
jti: SecureRandom.uuid,
|
69
|
+
iat: Time.now.to_i,
|
70
|
+
exp: Time.now.to_i + 300
|
71
|
+
}
|
72
|
+
|
73
|
+
JWT.encode(
|
74
|
+
jwt_payload,
|
75
|
+
private_key,
|
76
|
+
'ES256',
|
77
|
+
{
|
78
|
+
typ: 'jwt',
|
79
|
+
alg: 'ES256',
|
80
|
+
kid: options.client_jwk[:kid]
|
81
|
+
}
|
82
|
+
)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'jwt'
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
module OmniAuth
|
6
|
+
module Atproto
|
7
|
+
class KeyManager
|
8
|
+
class << self
|
9
|
+
KEY_PATH = 'config/atproto_private_key.pem'
|
10
|
+
JWK_PATH = 'config/atproto_jwk.json'
|
11
|
+
|
12
|
+
def generate_key_pair
|
13
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
14
|
+
private_key = key
|
15
|
+
public_key = key.public_key
|
16
|
+
|
17
|
+
# Get the coordinates for JWK
|
18
|
+
# (not easy with openssl 3)
|
19
|
+
bn = public_key.to_bn(:uncompressed)
|
20
|
+
raw_bytes = bn.to_s(2)
|
21
|
+
coord_bytes = raw_bytes[1..]
|
22
|
+
byte_length = coord_bytes.length / 2
|
23
|
+
|
24
|
+
x_coord = coord_bytes[0, byte_length]
|
25
|
+
y_coord = coord_bytes[byte_length, byte_length]
|
26
|
+
|
27
|
+
jwk = {
|
28
|
+
kty: 'EC',
|
29
|
+
crv: 'P-256',
|
30
|
+
x: Base64.urlsafe_encode64(x_coord, padding: false),
|
31
|
+
y: Base64.urlsafe_encode64(y_coord, padding: false),
|
32
|
+
use: 'sig',
|
33
|
+
alg: 'ES256',
|
34
|
+
kid: SecureRandom.uuid
|
35
|
+
}.freeze
|
36
|
+
|
37
|
+
[private_key, jwk]
|
38
|
+
end
|
39
|
+
|
40
|
+
def current_private_key
|
41
|
+
@current_private_key ||= load_or_generate_keys.first
|
42
|
+
end
|
43
|
+
|
44
|
+
def current_jwk
|
45
|
+
@current_jwk ||= load_or_generate_keys.last
|
46
|
+
end
|
47
|
+
|
48
|
+
def rotate_keys
|
49
|
+
# Backup current keys if they exist
|
50
|
+
if File.exist?(KEY_PATH)
|
51
|
+
File.write(KEY_PATH, 'config/old_atproto_private_key.pem')
|
52
|
+
FileUtils.rm(KEY_PATH)
|
53
|
+
end
|
54
|
+
if File.exist?(JWK_PATH)
|
55
|
+
File.write(JWK_PATH, 'config/old_atproto_jwk.json')
|
56
|
+
FileUtils.rm(JWK_PATH)
|
57
|
+
end
|
58
|
+
load_or_generate_keys
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def load_or_generate_keys
|
64
|
+
if File.exist?(KEY_PATH) && File.exist?(JWK_PATH)
|
65
|
+
private_key = OpenSSL::PKey::EC.new(File.read(KEY_PATH))
|
66
|
+
jwk = JSON.parse(File.read(JWK_PATH), symbolize_names: true)
|
67
|
+
else
|
68
|
+
private_key, jwk = generate_key_pair
|
69
|
+
File.write(KEY_PATH, private_key.to_pem)
|
70
|
+
File.write(JWK_PATH, JSON.pretty_generate(jwk))
|
71
|
+
end
|
72
|
+
[private_key, jwk]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module OmniAuth
|
2
|
+
module Atproto
|
3
|
+
class MetadataGenerator
|
4
|
+
def self.generate(options)
|
5
|
+
{
|
6
|
+
client_id: options[:client_id],
|
7
|
+
application_type: "web",
|
8
|
+
client_name: options[:client_name],
|
9
|
+
client_uri: options[:client_uri],
|
10
|
+
dpop_bound_access_tokens: true,
|
11
|
+
grant_types: ["authorization_code", "refresh_token"],
|
12
|
+
redirect_uris: [options[:redirect_uri]],
|
13
|
+
response_types: ["code"],
|
14
|
+
scope: options[:scope] || "atproto transition:generic",
|
15
|
+
token_endpoint_auth_method: "private_key_jwt",
|
16
|
+
token_endpoint_auth_signing_alg: "ES256",
|
17
|
+
jwks: {
|
18
|
+
keys: [options[:client_jwk]]
|
19
|
+
}
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
metadata
ADDED
@@ -0,0 +1,150 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: omniauth-atproto
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- frabr
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-11-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: atproto_client
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: jwt
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.7'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.7'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: omniauth-oauth2
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.8'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.8'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: omniauth-rails_csrf_protection
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '2.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '2.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '13.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '13.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '3.0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '3.0'
|
111
|
+
description: OmniAuth strategy for authenticating with AtProto services like Bluesky
|
112
|
+
email:
|
113
|
+
- frabr@lasercats.fr
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- README.md
|
119
|
+
- lib/omniauth-atproto.rb
|
120
|
+
- lib/omniauth-atproto/key_manager.rb
|
121
|
+
- lib/omniauth-atproto/metadata_generator.rb
|
122
|
+
- lib/omniauth-atproto/version.rb
|
123
|
+
- lib/omniauth/strategies/atproto.rb
|
124
|
+
homepage: https://github.com/lasercats/omniauth-atproto
|
125
|
+
licenses:
|
126
|
+
- MIT
|
127
|
+
metadata:
|
128
|
+
homepage_uri: https://github.com/lasercats/omniauth-atproto
|
129
|
+
source_code_uri: https://github.com/lasercats/omniauth-atproto
|
130
|
+
changelog_uri: https://github.com/lasercats/omniauth-atproto/blob/master/CHANGELOG.md
|
131
|
+
post_install_message:
|
132
|
+
rdoc_options: []
|
133
|
+
require_paths:
|
134
|
+
- lib
|
135
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
136
|
+
requirements:
|
137
|
+
- - ">="
|
138
|
+
- !ruby/object:Gem::Version
|
139
|
+
version: 2.6.0
|
140
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - ">="
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: '0'
|
145
|
+
requirements: []
|
146
|
+
rubygems_version: 3.5.3
|
147
|
+
signing_key:
|
148
|
+
specification_version: 4
|
149
|
+
summary: OmniAuth strategy for AtProto
|
150
|
+
test_files: []
|