atproto_auth 0.0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -2
  3. data/CHANGELOG.md +23 -2
  4. data/README.md +91 -5
  5. data/examples/confidential_client/.gitignore +2 -0
  6. data/examples/confidential_client/Gemfile +1 -0
  7. data/examples/confidential_client/Gemfile.lock +10 -1
  8. data/examples/confidential_client/README.md +86 -9
  9. data/examples/confidential_client/app.rb +83 -12
  10. data/examples/confidential_client/{public/client-metadata.json → config/client-metadata.example.json} +5 -4
  11. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  12. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  13. data/examples/confidential_client/scripts/generate_keys.rb +0 -0
  14. data/examples/confidential_client/views/authorized.erb +1 -1
  15. data/lib/atproto_auth/client.rb +98 -38
  16. data/lib/atproto_auth/client_metadata.rb +2 -2
  17. data/lib/atproto_auth/configuration.rb +35 -1
  18. data/lib/atproto_auth/dpop/key_manager.rb +1 -1
  19. data/lib/atproto_auth/dpop/nonce_manager.rb +30 -47
  20. data/lib/atproto_auth/encryption.rb +156 -0
  21. data/lib/atproto_auth/http_client.rb +2 -2
  22. data/lib/atproto_auth/identity/document.rb +1 -1
  23. data/lib/atproto_auth/identity/resolver.rb +1 -1
  24. data/lib/atproto_auth/serialization/base.rb +189 -0
  25. data/lib/atproto_auth/serialization/dpop_key.rb +29 -0
  26. data/lib/atproto_auth/serialization/session.rb +77 -0
  27. data/lib/atproto_auth/serialization/stored_nonce.rb +37 -0
  28. data/lib/atproto_auth/serialization/token_set.rb +43 -0
  29. data/lib/atproto_auth/server_metadata/authorization_server.rb +20 -1
  30. data/lib/atproto_auth/state/session_manager.rb +67 -20
  31. data/lib/atproto_auth/storage/interface.rb +112 -0
  32. data/lib/atproto_auth/storage/key_builder.rb +39 -0
  33. data/lib/atproto_auth/storage/memory.rb +191 -0
  34. data/lib/atproto_auth/storage/redis.rb +119 -0
  35. data/lib/atproto_auth/token/refresh.rb +249 -0
  36. data/lib/atproto_auth/version.rb +1 -1
  37. data/lib/atproto_auth.rb +29 -1
  38. metadata +32 -5
  39. data/examples/confidential_client/config/client-metadata.json +0 -25
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module AtprotoAuth
6
+ module Storage
7
+ # Thread-safe in-memory storage implementation
8
+ class Memory < Interface
9
+ def initialize
10
+ super
11
+ @data = {}
12
+ @locks = {}
13
+ @expirations = {}
14
+ @monitor = Monitor.new
15
+ @cleanup_interval = 60 # 1 minute
16
+ start_cleanup_thread
17
+ end
18
+
19
+ def set(key, value, ttl: nil)
20
+ validate_key!(key)
21
+ validate_ttl!(ttl)
22
+
23
+ @monitor.synchronize do
24
+ @data[key] = value
25
+ set_expiration(key, ttl) if ttl
26
+ true
27
+ end
28
+ end
29
+
30
+ def get(key)
31
+ validate_key!(key)
32
+
33
+ @monitor.synchronize do
34
+ return nil if expired?(key)
35
+
36
+ @data[key]
37
+ end
38
+ end
39
+
40
+ def delete(key)
41
+ validate_key!(key)
42
+
43
+ @monitor.synchronize do
44
+ @data.delete(key)
45
+ @expirations.delete(key)
46
+ true
47
+ end
48
+ end
49
+
50
+ def exists?(key)
51
+ validate_key!(key)
52
+
53
+ @monitor.synchronize do
54
+ return false unless @data.key?(key)
55
+ return false if expired?(key)
56
+
57
+ true
58
+ end
59
+ end
60
+
61
+ def multi_get(keys)
62
+ keys.each { |key| validate_key!(key) }
63
+
64
+ @monitor.synchronize do
65
+ result = {}
66
+ keys.each do |key|
67
+ result[key] = @data[key] if @data.key?(key) && !expired?(key)
68
+ end
69
+ result
70
+ end
71
+ end
72
+
73
+ def multi_set(hash, ttl: nil)
74
+ hash.each_key { |key| validate_key!(key) }
75
+ validate_ttl!(ttl)
76
+
77
+ @monitor.synchronize do
78
+ hash.each do |key, value|
79
+ @data[key] = value
80
+ set_expiration(key, ttl) if ttl
81
+ end
82
+ true
83
+ end
84
+ end
85
+
86
+ def acquire_lock(key, ttl:)
87
+ validate_key!(key)
88
+ validate_ttl!(ttl)
89
+ lock_key = "lock:#{key}"
90
+
91
+ @monitor.synchronize do
92
+ return false if @locks[lock_key] && !lock_expired?(lock_key)
93
+
94
+ @locks[lock_key] = Time.now.to_i + ttl
95
+ true
96
+ end
97
+ end
98
+
99
+ def release_lock(key)
100
+ validate_key!(key)
101
+ lock_key = "lock:#{key}"
102
+
103
+ @monitor.synchronize do
104
+ @locks.delete(lock_key)
105
+ true
106
+ end
107
+ end
108
+
109
+ def with_lock(key, ttl: 30)
110
+ raise ArgumentError, "Block required" unless block_given?
111
+
112
+ acquired = acquire_lock(key, ttl: ttl)
113
+ raise LockError, "Failed to acquire lock" unless acquired
114
+
115
+ begin
116
+ yield
117
+ ensure
118
+ release_lock(key)
119
+ end
120
+ end
121
+
122
+ def clear
123
+ @monitor.synchronize do
124
+ @data.clear
125
+ @expirations.clear
126
+ @locks.clear
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def expired?(key)
133
+ return false unless @expirations.key?(key)
134
+
135
+ expiry = @expirations[key]
136
+ if Time.now.to_i >= expiry
137
+ @data.delete(key)
138
+ @expirations.delete(key)
139
+ true
140
+ else
141
+ false
142
+ end
143
+ end
144
+
145
+ def lock_expired?(lock_key)
146
+ expiry = @locks[lock_key]
147
+ return false unless expiry
148
+
149
+ if Time.now.to_i >= expiry
150
+ @locks.delete(lock_key)
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+
157
+ def set_expiration(key, ttl)
158
+ @expirations[key] = Time.now.to_i + ttl
159
+ end
160
+
161
+ def cleanup_expired
162
+ @monitor.synchronize do
163
+ now = Time.now.to_i
164
+
165
+ # Cleanup expired data
166
+ @expirations.each do |key, expiry|
167
+ if now >= expiry
168
+ @data.delete(key)
169
+ @expirations.delete(key)
170
+ end
171
+ end
172
+
173
+ # Cleanup expired locks
174
+ @locks.delete_if { |_, expiry| now >= expiry }
175
+ end
176
+ end
177
+
178
+ def start_cleanup_thread
179
+ Thread.new do
180
+ loop do
181
+ sleep @cleanup_interval
182
+ cleanup_expired
183
+ rescue StandardError => e
184
+ # Log error but keep thread running
185
+ AtprotoAuth.configuration.logger.error "Storage cleanup error: #{e.message}"
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ module AtprotoAuth
6
+ module Storage
7
+ # Redis storage implementation
8
+ class Redis < Interface
9
+ # Error raised for Redis-specific issues
10
+ class RedisError < StorageError; end
11
+
12
+ def initialize(redis_client: nil)
13
+ super()
14
+ @redis_client = redis_client || ::Redis.new
15
+ end
16
+
17
+ def set(key, value, ttl: nil)
18
+ validate_key!(key)
19
+ validate_ttl!(ttl) if ttl
20
+
21
+ @redis_client.set(key, value, ex: ttl)
22
+ true
23
+ rescue ::Redis::BaseError => e
24
+ raise RedisError, "Failed to set value: #{e.message}"
25
+ end
26
+
27
+ def get(key)
28
+ validate_key!(key)
29
+
30
+ value = @redis_client.get(key)
31
+ value.nil? || value == "" ? nil : value
32
+ rescue ::Redis::BaseError => e
33
+ raise RedisError, "Failed to get value: #{e.message}"
34
+ end
35
+
36
+ def delete(key)
37
+ validate_key!(key)
38
+
39
+ @redis_client.del(key).positive?
40
+ rescue ::Redis::BaseError => e
41
+ raise RedisError, "Failed to delete value: #{e.message}"
42
+ end
43
+
44
+ def exists?(key)
45
+ validate_key!(key)
46
+
47
+ @redis_client.exists?(key)
48
+ rescue ::Redis::BaseError => e
49
+ raise RedisError, "Failed to check existence: #{e.message}"
50
+ end
51
+
52
+ def multi_get(keys)
53
+ keys.each { |key| validate_key!(key) }
54
+
55
+ values = @redis_client.mget(keys)
56
+ result = {}
57
+
58
+ # Only include non-nil values in result hash
59
+ keys.zip(values).each do |key, value|
60
+ next if value.nil? || value == ""
61
+
62
+ result[key] = value
63
+ end
64
+
65
+ result
66
+ rescue ::Redis::BaseError => e
67
+ raise RedisError, "Failed to get multiple values: #{e.message}"
68
+ end
69
+
70
+ def multi_set(hash, ttl: nil)
71
+ hash.each_key { |key| validate_key!(key) }
72
+ validate_ttl!(ttl) if ttl
73
+
74
+ @redis_client.multi do |tx|
75
+ hash.each do |key, value|
76
+ tx.set(key, value, ex: ttl)
77
+ end
78
+ end
79
+ true
80
+ rescue ::Redis::BaseError => e
81
+ raise RedisError, "Failed to set multiple values: #{e.message}"
82
+ end
83
+
84
+ def acquire_lock(key, ttl:)
85
+ validate_key!(key)
86
+ validate_ttl!(ttl)
87
+
88
+ lock_key = "atproto:locks:#{key}"
89
+ @redis_client.set(lock_key, Time.now.to_i, nx: true, ex: ttl)
90
+ rescue ::Redis::BaseError => e
91
+ raise RedisError, "Failed to acquire lock: #{e.message}"
92
+ end
93
+
94
+ def release_lock(key)
95
+ validate_key!(key)
96
+
97
+ lock_key = "atproto:locks:#{key}"
98
+ @redis_client.del(lock_key).positive?
99
+ rescue ::Redis::BaseError => e
100
+ raise RedisError, "Failed to release lock: #{e.message}"
101
+ end
102
+
103
+ def with_lock(key, ttl: 30)
104
+ raise ArgumentError, "Block required" unless block_given?
105
+
106
+ acquired = acquire_lock(key, ttl: ttl)
107
+ raise LockError, "Failed to acquire lock" unless acquired
108
+
109
+ begin
110
+ yield
111
+ ensure
112
+ release_lock(key)
113
+ end
114
+ rescue ::Redis::BaseError => e
115
+ raise RedisError, "Lock operation failed: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Token
5
+ # Base error class for token-related errors
6
+ class Error < AtprotoAuth::Error
7
+ attr_reader :token_type, :error_code, :retry_possible
8
+
9
+ def initialize(message, token_type:, error_code:, retry_possible: false)
10
+ @token_type = token_type
11
+ @error_code = error_code
12
+ @retry_possible = retry_possible
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ # Specific token error types
18
+ class ExpiredTokenError < Error
19
+ def initialize(token_type:)
20
+ super(
21
+ "Token has expired",
22
+ token_type: token_type,
23
+ error_code: "token_expired",
24
+ retry_possible: true
25
+ )
26
+ end
27
+ end
28
+
29
+ # Raised when a token is structurally valid but has been invalidated or revoked
30
+ class InvalidTokenError < Error
31
+ def initialize(token_type:)
32
+ super(
33
+ "Token is invalid",
34
+ token_type: token_type,
35
+ error_code: "token_invalid",
36
+ retry_possible: false
37
+ )
38
+ end
39
+ end
40
+
41
+ # Raised during token refresh operations, includes retry information and server responses
42
+ class RefreshError < Error
43
+ def initialize(message, retry_possible: true)
44
+ super(
45
+ message,
46
+ token_type: "refresh",
47
+ error_code: "refresh_failed",
48
+ retry_possible: retry_possible
49
+ )
50
+ end
51
+ end
52
+
53
+ # Handles token refresh operations with retry logic
54
+ class Refresh
55
+ include MonitorMixin
56
+
57
+ # Maximum number of refresh attempts
58
+ MAX_RETRIES = 3
59
+ # Base delay between retries in seconds
60
+ BASE_DELAY = 1
61
+ # Maximum delay between retries in seconds
62
+ MAX_DELAY = 8
63
+
64
+ attr_reader :session, :dpop_client, :auth_server, :client_metadata
65
+
66
+ def initialize(session:, dpop_client:, auth_server:, client_metadata:)
67
+ super() # Initialize MonitorMixin
68
+ @session = session
69
+ @dpop_client = dpop_client
70
+ @auth_server = auth_server
71
+ @attempt_count = 0
72
+ @client_metadata = client_metadata
73
+ end
74
+
75
+ # Performs token refresh with retry logic
76
+ # @return [TokenSet] New token set
77
+ # @raise [RefreshError] if refresh fails
78
+ def perform!
79
+ synchronize do
80
+ raise RefreshError.new("No refresh token available", retry_possible: false) unless session.renewable?
81
+ raise RefreshError.new("No access token to refresh", retry_possible: false) if session.tokens.nil?
82
+
83
+ with_retries do
84
+ request_token_refresh
85
+ end
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def with_retries
92
+ @attempt_count = 0
93
+ last_error = nil
94
+
95
+ while @attempt_count < MAX_RETRIES
96
+ begin
97
+ return yield
98
+ rescue StandardError => e
99
+ last_error = e
100
+ @attempt_count += 1
101
+
102
+ # Don't retry if error indicates retry not possible
103
+ raise e if e.respond_to?(:retry_possible) && !e.retry_possible
104
+
105
+ sleep calculate_delay if @attempt_count < MAX_RETRIES
106
+ end
107
+ end
108
+
109
+ # Reached max retries
110
+ raise RefreshError.new(
111
+ "Token refresh failed after #{MAX_RETRIES} attempts: #{last_error.message}",
112
+ retry_possible: false
113
+ )
114
+ end
115
+
116
+ def calculate_delay
117
+ # Exponential backoff with jitter
118
+ delay = [BASE_DELAY * (2**(@attempt_count - 1)), MAX_DELAY].min
119
+ delay + (rand * 0.5 * delay) # Add up to 50% jitter
120
+ end
121
+
122
+ def request_token_refresh
123
+ # Generate DPoP proof for token request
124
+ proof = dpop_client.generate_proof(
125
+ http_method: "POST",
126
+ http_uri: auth_server.token_endpoint
127
+ )
128
+
129
+ # Build refresh request
130
+ body = {
131
+ grant_type: "refresh_token",
132
+ refresh_token: session.tokens.refresh_token,
133
+ scope: session.scope
134
+ }
135
+
136
+ # Add client authentication if available
137
+ add_client_authentication!(body) if client_metadata.confidential?
138
+
139
+ # Make request
140
+ response = AtprotoAuth.configuration.http_client.post(
141
+ auth_server.token_endpoint,
142
+ body: body,
143
+ headers: {
144
+ "Content-Type" => "application/x-www-form-urlencoded",
145
+ "DPoP" => proof
146
+ }
147
+ )
148
+
149
+ handle_refresh_response(response)
150
+ end
151
+
152
+ def add_client_authentication!(body)
153
+ return unless session.client_metadata.jwks && !session.client_metadata.jwks["keys"].empty?
154
+
155
+ signing_key = JOSE::JWK.from_map(session.client_metadata.jwks["keys"].first)
156
+ client_assertion = PAR::ClientAssertion.new(
157
+ client_id: session.client_metadata.client_id,
158
+ signing_key: signing_key
159
+ )
160
+
161
+ body.merge!(
162
+ client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
163
+ client_assertion: client_assertion.generate_jwt(
164
+ audience: auth_server.issuer
165
+ )
166
+ )
167
+ end
168
+
169
+ def handle_refresh_response(response)
170
+ case response[:status]
171
+ when 200
172
+ process_successful_response(response)
173
+ when 400
174
+ handle_400_response(response)
175
+ when 401
176
+ raise RefreshError.new("Refresh token is invalid", retry_possible: false)
177
+ when 429
178
+ handle_rate_limit_response(response)
179
+ else
180
+ raise RefreshError, "Unexpected response: #{response[:status]}"
181
+ end
182
+ end
183
+
184
+ def process_successful_response(response)
185
+ data = JSON.parse(response[:body])
186
+ validate_refresh_response!(data)
187
+
188
+ AtprotoAuth::State::TokenSet.new(
189
+ access_token: data["access_token"],
190
+ token_type: data["token_type"],
191
+ expires_in: data["expires_in"],
192
+ refresh_token: data["refresh_token"],
193
+ scope: data["scope"],
194
+ sub: data["sub"]
195
+ )
196
+ rescue JSON::ParserError => e
197
+ raise RefreshError, "Invalid response format: #{e.message}"
198
+ end
199
+
200
+ def handle_400_response(response)
201
+ error_data = JSON.parse(response[:body])
202
+ error_description = error_data["error_description"] || error_data["error"]
203
+
204
+ # Handle DPoP nonce requirement
205
+ if error_data["error"] == "use_dpop_nonce"
206
+ dpop_client.process_response(response[:headers], auth_server.issuer)
207
+ raise RefreshError, "Retry with DPoP nonce"
208
+ end
209
+
210
+ raise RefreshError.new(
211
+ "Refresh request failed: #{error_description}",
212
+ retry_possible: false
213
+ )
214
+ rescue JSON::ParserError
215
+ raise RefreshError, "Invalid error response format"
216
+ end
217
+
218
+ def handle_rate_limit_response(response)
219
+ # Extract retry-after if available
220
+ retry_after = response[:headers]["Retry-After"]&.to_i || calculate_delay
221
+ raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
222
+ end
223
+
224
+ def validate_refresh_response!(data)
225
+ # Required fields
226
+ %w[access_token token_type expires_in scope sub].each do |field|
227
+ raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
228
+ end
229
+
230
+ # Token type must be DPoP
231
+ unless data["token_type"] == "DPoP"
232
+ raise RefreshError.new("Invalid token_type: #{data["token_type"]}", retry_possible: false)
233
+ end
234
+
235
+ # Scope must include original scopes
236
+ original_scopes = session.scope.split
237
+ response_scopes = data["scope"].split
238
+ unless (original_scopes - response_scopes).empty?
239
+ raise RefreshError.new("Invalid scope in response", retry_possible: false)
240
+ end
241
+
242
+ # Subject must match
243
+ return if data["sub"] == session.tokens.sub
244
+
245
+ raise RefreshError.new("Subject mismatch in response", retry_possible: false)
246
+ end
247
+ end
248
+ end
249
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtprotoAuth
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/atproto_auth.rb CHANGED
@@ -5,12 +5,18 @@ require "jwt"
5
5
 
6
6
  require "atproto_auth/version"
7
7
 
8
- require "atproto_auth/configuration"
9
8
  require "atproto_auth/errors"
9
+ require "atproto_auth/configuration"
10
+ require "atproto_auth/encryption"
10
11
  require "atproto_auth/client_metadata"
11
12
  require "atproto_auth/http_client"
12
13
  require "atproto_auth/pkce"
13
14
 
15
+ require "atproto_auth/storage/interface"
16
+ require "atproto_auth/storage/key_builder"
17
+ require "atproto_auth/storage/memory"
18
+ require "atproto_auth/storage/redis"
19
+
14
20
  require "atproto_auth/server_metadata"
15
21
  require "atproto_auth/server_metadata/origin_url"
16
22
  require "atproto_auth/server_metadata/authorization_server"
@@ -36,6 +42,14 @@ require "atproto_auth/par/request"
36
42
  require "atproto_auth/par/response"
37
43
  require "atproto_auth/par/client"
38
44
 
45
+ require "atproto_auth/serialization/base"
46
+ require "atproto_auth/serialization/dpop_key"
47
+ require "atproto_auth/serialization/session"
48
+ require "atproto_auth/serialization/stored_nonce"
49
+ require "atproto_auth/serialization/token_set"
50
+
51
+ require "atproto_auth/token/refresh"
52
+
39
53
  require "atproto_auth/client"
40
54
 
41
55
  # AtprotoAuth is a Ruby library implementing the AT Protocol OAuth specification.
@@ -51,6 +65,20 @@ module AtprotoAuth
51
65
 
52
66
  def configure
53
67
  yield(configuration)
68
+ configuration.validate!
69
+ configuration
70
+ end
71
+
72
+ # Gets the configured storage backend
73
+ # @return [Storage::Interface] The configured storage implementation
74
+ def storage
75
+ configuration.storage
76
+ end
77
+
78
+ # Resets the configuration to defaults
79
+ # Primarily used in testing
80
+ def reset_configuration!
81
+ @configuration = Configuration.new
54
82
  end
55
83
  end
56
84
  end