atproto_auth 0.0.1 → 0.1.0

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/PROJECT_STRUCTURE.txt +10129 -0
  5. data/README.md +88 -2
  6. data/examples/confidential_client/.gitignore +2 -0
  7. data/examples/confidential_client/Gemfile.lock +6 -0
  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 -4
  39. data/examples/confidential_client/config/client-metadata.json +0 -25
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
4
-
5
3
  module AtprotoAuth
6
4
  # Main client class for AT Protocol OAuth implementation. Handles the complete
7
5
  # OAuth flow including authorization, token management, and identity verification.
@@ -12,6 +10,16 @@ module AtprotoAuth
12
10
  # Error raised when token operations fail
13
11
  class TokenError < Error; end
14
12
 
13
+ # Error raised when session operations fail
14
+ class SessionError < Error
15
+ attr_reader :session_id
16
+
17
+ def initialize(message, session_id: nil)
18
+ @session_id = session_id
19
+ super(message)
20
+ end
21
+ end
22
+
15
23
  # @return [String] OAuth client ID
16
24
  attr_reader :client_id
17
25
  # @return [String] OAuth redirect URI
@@ -59,6 +67,9 @@ module AtprotoAuth
59
67
  scope: scope
60
68
  )
61
69
 
70
+ # Store session with storage backend
71
+ session_manager.update_session(session)
72
+
62
73
  # Resolve identity and authorization server if handle provided
63
74
  if handle
64
75
  auth_info = resolve_from_handle(handle, session)
@@ -96,34 +107,39 @@ module AtprotoAuth
96
107
  # Verify issuer matches session
97
108
  raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
98
109
 
99
- # Exchange code for tokens
100
- token_response = exchange_code(
101
- code: code,
102
- session: session
103
- )
104
-
105
- # Validate token response
106
- validate_token_response!(token_response, session)
107
-
108
- # Create token set and store in session
109
- token_set = State::TokenSet.new(
110
- access_token: token_response["access_token"],
111
- token_type: token_response["token_type"],
112
- expires_in: token_response["expires_in"],
113
- refresh_token: token_response["refresh_token"],
114
- scope: token_response["scope"],
115
- sub: token_response["sub"]
116
- )
117
- session.tokens = token_set
110
+ AtprotoAuth.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
111
+ # Exchange code for tokens
112
+ token_response = exchange_code(
113
+ code: code,
114
+ session: session
115
+ )
118
116
 
119
- {
120
- access_token: token_set.access_token,
121
- token_type: token_set.token_type,
122
- expires_in: (token_set.expires_at - Time.now).to_i,
123
- refresh_token: token_set.refresh_token,
124
- scope: token_set.scope,
125
- session_id: session.session_id
126
- }
117
+ # Validate token response
118
+ validate_token_response!(token_response, session)
119
+
120
+ # Create token set and store in session
121
+ token_set = State::TokenSet.new(
122
+ access_token: token_response["access_token"],
123
+ token_type: token_response["token_type"],
124
+ expires_in: token_response["expires_in"],
125
+ refresh_token: token_response["refresh_token"],
126
+ scope: token_response["scope"],
127
+ sub: token_response["sub"]
128
+ )
129
+ session.tokens = token_set
130
+
131
+ # Update stored session
132
+ session_manager.update_session(session)
133
+
134
+ {
135
+ access_token: token_set.access_token,
136
+ token_type: token_set.token_type,
137
+ expires_in: (token_set.expires_at - Time.now).to_i,
138
+ refresh_token: token_set.refresh_token,
139
+ scope: token_set.scope,
140
+ session_id: session.session_id
141
+ }
142
+ end
127
143
  end
128
144
 
129
145
  # Gets active tokens for a session
@@ -142,6 +158,38 @@ module AtprotoAuth
142
158
  }
143
159
  end
144
160
 
161
+ # Refreshes tokens for a session
162
+ # @param session_id [String] ID of session to refresh
163
+ def refresh_token(session_id)
164
+ session = session_manager.get_session(session_id)
165
+ raise TokenError, "Invalid session" unless session
166
+ raise TokenError, "Session not authorized" unless session.renewable?
167
+
168
+ AtprotoAuth.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
169
+ refresher = Token::Refresh.new(
170
+ session: session,
171
+ dpop_client: @dpop_client,
172
+ auth_server: session.auth_server,
173
+ client_metadata: client_metadata
174
+ )
175
+
176
+ new_tokens = refresher.perform!
177
+ session.tokens = new_tokens
178
+
179
+ # Update stored session
180
+ session_manager.update_session(session)
181
+
182
+ {
183
+ access_token: new_tokens.access_token,
184
+ token_type: new_tokens.token_type,
185
+ expires_in: (new_tokens.expires_at - Time.now).to_i,
186
+ refresh_token: new_tokens.refresh_token,
187
+ scope: new_tokens.scope,
188
+ session_id: session.session_id
189
+ }
190
+ end
191
+ end
192
+
145
193
  # Checks if a session has valid tokens
146
194
  # @param session_id [String] ID of session to check
147
195
  # @return [Boolean] true if session exists and has valid tokens
@@ -174,6 +222,21 @@ module AtprotoAuth
174
222
  }
175
223
  end
176
224
 
225
+ # Removes a session and its stored data
226
+ # @param session_id [String] ID of session to remove
227
+ # @return [void]
228
+ def remove_session(session_id)
229
+ key = Storage::KeyBuilder.session_key(session_id)
230
+ AtprotoAuth.storage.delete(key)
231
+ session_manager.remove_session(session_id)
232
+ end
233
+
234
+ # Cleans up expired sessions from storage
235
+ # @return [void]
236
+ def cleanup_expired_sessions
237
+ session_manager.cleanup_expired
238
+ end
239
+
177
240
  private
178
241
 
179
242
  def load_client_metadata(metadata)
@@ -214,6 +277,9 @@ module AtprotoAuth
214
277
  server = resolve_auth_server(resolution[:pds])
215
278
  session.authorization_server = server
216
279
 
280
+ # Update stored session
281
+ session_manager.update_session(session)
282
+
217
283
  { server: server, pds: resolution[:pds] }
218
284
  end
219
285
 
@@ -222,6 +288,9 @@ module AtprotoAuth
222
288
  server = resolve_auth_server(pds_url)
223
289
  session.authorization_server = server
224
290
 
291
+ # Update stored session
292
+ session_manager.update_session(session)
293
+
225
294
  { server: server, pds: pds_url }
226
295
  end
227
296
 
@@ -296,8 +365,6 @@ module AtprotoAuth
296
365
 
297
366
  # Handle DPoP nonce requirement
298
367
  if requires_dpop_nonce?(response)
299
- puts "*" * 88
300
- puts "requires_dpop_nonce"
301
368
  # Extract and store nonce from error response
302
369
  extract_dpop_nonce(response)
303
370
  dpop_client.process_response(response[:headers], session.auth_server.issuer)
@@ -322,11 +389,6 @@ module AtprotoAuth
322
389
  http_uri: session.auth_server.token_endpoint
323
390
  )
324
391
 
325
- # Log the DPoP proof details
326
- AtprotoAuth.configuration.logger.debug "Token Request DPoP Proof:"
327
- AtprotoAuth.configuration.logger.debug "- Key: #{dpop_client.public_key}"
328
- AtprotoAuth.configuration.logger.debug "- Proof: #{proof}"
329
-
330
392
  body = {
331
393
  grant_type: "authorization_code",
332
394
  code: code,
@@ -406,5 +468,3 @@ module AtprotoAuth
406
468
  end
407
469
  end
408
470
  end
409
-
410
- # rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
@@ -56,7 +56,7 @@ module AtprotoAuth
56
56
 
57
57
  private
58
58
 
59
- def validate_and_set_metadata!(metadata) # rubocop:disable Metrics/AbcSize
59
+ def validate_and_set_metadata!(metadata)
60
60
  # Required fields
61
61
  @application_type = validate_application_type(metadata["application_type"])
62
62
  @client_id = validate_client_id!(metadata["client_id"])
@@ -208,7 +208,7 @@ module AtprotoAuth
208
208
  end
209
209
  end
210
210
 
211
- def validate_auth_methods!(metadata) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
211
+ def validate_auth_methods!(metadata)
212
212
  @token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
213
213
  return unless @token_endpoint_auth_method == "private_key_jwt"
214
214
 
@@ -3,15 +3,49 @@
3
3
  require "logger"
4
4
 
5
5
  module AtprotoAuth
6
+ class ConfigurationError < Error; end
7
+
6
8
  # Configuration class for global AtprotoAuth settings
7
9
  class Configuration
8
- attr_accessor :default_token_lifetime, :dpop_nonce_lifetime, :http_client, :logger
10
+ attr_accessor :default_token_lifetime,
11
+ :dpop_nonce_lifetime,
12
+ :encryption,
13
+ :http_client,
14
+ :logger,
15
+ :storage
9
16
 
10
17
  def initialize
11
18
  @default_token_lifetime = 300 # 5 minutes in seconds
12
19
  @dpop_nonce_lifetime = 300 # 5 minutes in seconds
20
+ @encryption = nil
13
21
  @http_client = nil
14
22
  @logger = Logger.new($stdout)
23
+ @storage = AtprotoAuth::Storage::Memory.new
24
+ end
25
+
26
+ # Validates the current configuration
27
+ # @raise [ConfigurationError] if configuration is invalid
28
+ def validate!
29
+ validate_storage!
30
+ validate_http_client!
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def validate_storage!
37
+ raise ConfigurationError, "Storage must be configured" if @storage.nil?
38
+ return if @storage.is_a?(AtprotoAuth::Storage::Interface)
39
+
40
+ raise ConfigurationError, "Storage must implement Storage::Interface"
41
+ end
42
+
43
+ def validate_http_client!
44
+ return if @http_client.nil? # Allow nil for testing
45
+
46
+ return if @http_client.respond_to?(:get) && @http_client.respond_to?(:post)
47
+
48
+ raise ConfigurationError, "HTTP client must implement get and post methods"
15
49
  end
16
50
  end
17
51
  end
@@ -189,7 +189,7 @@ module AtprotoAuth
189
189
  asn1_to_raw(signature)
190
190
  end
191
191
 
192
- def extract_ec_key # rubocop:disable Metrics/AbcSize
192
+ def extract_ec_key
193
193
  # Extract the raw EC key from JOSE JWK
194
194
  key_data = @keypair.to_map
195
195
  raise KeyError, "Private key required for signing" unless key_data["d"] # Private key component
@@ -1,40 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
3
  module AtprotoAuth
6
4
  module DPoP
7
5
  # Manages DPoP nonces provided by servers during the OAuth flow.
8
- # Tracks separate nonces for Resource Server and Authorization Server.
6
+ # Tracks separate nonces for each server using persistent storage.
9
7
  # Thread-safe to handle concurrent requests.
10
8
  class NonceManager
11
9
  # Error for nonce-related issues
12
10
  class NonceError < AtprotoAuth::Error; end
13
11
 
14
- # Represents a stored nonce with its timestamp
12
+ # Represents a stored nonce with its server URL
15
13
  class StoredNonce
16
- attr_reader :value, :timestamp, :server_url
14
+ attr_reader :value, :server_url, :timestamp
17
15
 
18
- def initialize(value, server_url)
16
+ def initialize(value, server_url, timestamp: nil)
19
17
  @value = value
20
18
  @server_url = server_url
21
- @timestamp = Time.now.to_i
22
- end
23
-
24
- def expired?(ttl = nil)
25
- return false unless ttl
26
-
27
- (Time.now.to_i - @timestamp) > ttl
19
+ @timestamp = timestamp || Time.now.to_i
28
20
  end
29
21
  end
30
22
 
31
- # Maximum time in seconds a nonce is considered valid
23
+ # Default time in seconds a nonce is considered valid
32
24
  DEFAULT_TTL = 300 # 5 minutes
33
25
 
34
26
  def initialize(ttl: nil)
35
27
  @ttl = ttl || DEFAULT_TTL
36
- @nonces = {}
37
- @monitor = Monitor.new
28
+ @serializer = Serialization::StoredNonce.new
38
29
  end
39
30
 
40
31
  # Updates the stored nonce for a server
@@ -45,9 +36,13 @@ module AtprotoAuth
45
36
  validate_inputs!(nonce, server_url)
46
37
  origin = normalize_server_url(server_url)
47
38
 
48
- @monitor.synchronize do
49
- @nonces[origin] = StoredNonce.new(nonce, origin)
50
- end
39
+ stored_nonce = StoredNonce.new(nonce, origin)
40
+ serialized = @serializer.serialize(stored_nonce)
41
+
42
+ key = Storage::KeyBuilder.nonce_key(origin)
43
+ return if AtprotoAuth.storage.set(key, serialized, ttl: @ttl)
44
+
45
+ raise NonceError, "Failed to store nonce"
51
46
  end
52
47
 
53
48
  # Gets the current nonce for a server
@@ -57,36 +52,26 @@ module AtprotoAuth
57
52
  def get(server_url)
58
53
  validate_server_url!(server_url)
59
54
  origin = normalize_server_url(server_url)
55
+ key = Storage::KeyBuilder.nonce_key(origin)
60
56
 
61
- @monitor.synchronize do
62
- stored = @nonces[origin]
63
- return nil if stored.nil? || stored.expired?(@ttl)
57
+ stored = AtprotoAuth.storage.get(key)
58
+ return nil unless stored
64
59
 
65
- stored.value
60
+ begin
61
+ stored_nonce = @serializer.deserialize(stored)
62
+ stored_nonce.value
63
+ rescue Serialization::Error => e
64
+ raise NonceError, "Failed to deserialize nonce: #{e.message}"
66
65
  end
67
66
  end
68
67
 
69
- # Clears an expired nonce for a server
68
+ # Clears a nonce for a server
70
69
  # @param server_url [String] The server's URL
71
70
  def clear(server_url)
72
- @monitor.synchronize do
73
- @nonces.delete(server_url)
74
- end
75
- end
76
-
77
- # Clears all stored nonces
78
- def clear_all
79
- @monitor.synchronize do
80
- @nonces.clear
81
- end
82
- end
83
-
84
- # Get all currently stored server URLs
85
- # @return [Array<String>] Array of server URLs with stored nonces
86
- def server_urls
87
- @monitor.synchronize do
88
- @nonces.keys
89
- end
71
+ validate_server_url!(server_url)
72
+ origin = normalize_server_url(server_url)
73
+ key = Storage::KeyBuilder.nonce_key(origin)
74
+ AtprotoAuth.storage.delete(key)
90
75
  end
91
76
 
92
77
  # Check if a server has a valid nonce
@@ -94,11 +79,9 @@ module AtprotoAuth
94
79
  # @return [Boolean] true if server has a valid nonce
95
80
  def valid_nonce?(server_url)
96
81
  validate_server_url!(server_url)
97
-
98
- @monitor.synchronize do
99
- stored = @nonces[server_url]
100
- !stored.nil? && !stored.expired?(@ttl)
101
- end
82
+ origin = normalize_server_url(server_url)
83
+ key = Storage::KeyBuilder.nonce_key(origin)
84
+ AtprotoAuth.storage.exists?(key)
102
85
  end
103
86
 
104
87
  private
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/atproto_auth/encryption.rb
4
+ module AtprotoAuth
5
+ module Encryption
6
+ class Error < AtprotoAuth::Error; end
7
+ class ConfigurationError < Error; end
8
+ class EncryptionError < Error; end
9
+ class DecryptionError < Error; end
10
+
11
+ # HKDF implementation based on RFC 5869
12
+ module HKDF
13
+ def self.derive(secret, salt:, info:, length:)
14
+ # 1. extract
15
+ prk = OpenSSL::HMAC.digest(
16
+ OpenSSL::Digest.new("SHA256"),
17
+ salt.empty? ? "\x00" * 32 : salt,
18
+ secret.to_s
19
+ )
20
+
21
+ # 2. expand
22
+ n = (length.to_f / 32).ceil
23
+ t = [""]
24
+ output = ""
25
+ 1.upto(n) do |i|
26
+ t[i] = OpenSSL::HMAC.digest(
27
+ OpenSSL::Digest.new("SHA256"),
28
+ prk,
29
+ t[i - 1] + info + [i].pack("C")
30
+ )
31
+ output += t[i]
32
+ end
33
+ output[0, length]
34
+ end
35
+ end
36
+
37
+ # Core encryption service - used internally by serializers
38
+ class Service
39
+ CIPHER = "aes-256-gcm"
40
+ VERSION = 1
41
+
42
+ def initialize
43
+ @key_provider = KeyProvider.new
44
+ end
45
+
46
+ def encrypt(data, context:)
47
+ validate_encryption_inputs!(data, context)
48
+
49
+ iv = SecureRandom.random_bytes(12)
50
+
51
+ cipher = OpenSSL::Cipher.new(CIPHER)
52
+ cipher.encrypt
53
+ cipher.key = @key_provider.key_for_context(context)
54
+ cipher.iv = iv
55
+ cipher.auth_data = context.to_s
56
+
57
+ encrypted = cipher.update(data.to_s) + cipher.final
58
+ auth_tag = cipher.auth_tag
59
+
60
+ {
61
+ version: VERSION,
62
+ iv: Base64.strict_encode64(iv),
63
+ data: Base64.strict_encode64(encrypted),
64
+ tag: Base64.strict_encode64(auth_tag)
65
+ }
66
+ rescue StandardError => e
67
+ raise EncryptionError, "Encryption failed: #{e.message}"
68
+ end
69
+
70
+ def decrypt(encrypted, context:)
71
+ validate_decryption_inputs!(encrypted, context)
72
+ validate_encrypted_data!(encrypted)
73
+
74
+ iv = Base64.strict_decode64(encrypted[:iv])
75
+ data = Base64.strict_decode64(encrypted[:data])
76
+ auth_tag = Base64.strict_decode64(encrypted[:tag])
77
+
78
+ cipher = OpenSSL::Cipher.new(CIPHER)
79
+ cipher.decrypt
80
+ cipher.key = @key_provider.key_for_context(context)
81
+ cipher.iv = iv
82
+ cipher.auth_tag = auth_tag
83
+ cipher.auth_data = context.to_s
84
+
85
+ cipher.update(data) + cipher.final
86
+ rescue ArgumentError => e
87
+ raise DecryptionError, "Invalid encrypted data format: #{e.message}"
88
+ rescue StandardError => e
89
+ raise DecryptionError, "Decryption failed: #{e.message}"
90
+ end
91
+
92
+ private
93
+
94
+ def validate_encryption_inputs!(data, context)
95
+ raise EncryptionError, "Data cannot be nil" if data.nil?
96
+ raise EncryptionError, "Context cannot be nil" if context.nil?
97
+ raise EncryptionError, "Context must be a string" unless context.is_a?(String)
98
+ raise EncryptionError, "Context cannot be empty" if context.empty?
99
+ end
100
+
101
+ def validate_decryption_inputs!(encrypted, context)
102
+ raise DecryptionError, "Encrypted data cannot be nil" if encrypted.nil?
103
+ raise DecryptionError, "Context cannot be nil" if context.nil?
104
+ raise DecryptionError, "Context must be a string" unless context.is_a?(String)
105
+ raise DecryptionError, "Context cannot be empty" if context.empty?
106
+ end
107
+
108
+ def validate_encrypted_data!(encrypted)
109
+ raise DecryptionError, "Invalid encrypted data format" unless encrypted.is_a?(Hash)
110
+
111
+ unless encrypted[:version] == VERSION
112
+ raise DecryptionError, "Unsupported encryption version: #{encrypted[:version]}"
113
+ end
114
+
115
+ %i[iv data tag].each do |field|
116
+ raise DecryptionError, "Missing required field: #{field}" unless encrypted[field].is_a?(String)
117
+ end
118
+ end
119
+ end
120
+
121
+ # Handles key management and derivation
122
+ class KeyProvider
123
+ def initialize
124
+ @master_key = load_master_key
125
+ end
126
+
127
+ def key_for_context(context)
128
+ raise ConfigurationError, "Context is required" if context.nil?
129
+
130
+ HKDF.derive(
131
+ @master_key,
132
+ salt: salt_for_context(context),
133
+ info: "atproto-#{context}",
134
+ length: 32
135
+ )
136
+ end
137
+
138
+ private
139
+
140
+ def load_master_key
141
+ # Try environment variable first
142
+ key = ENV.fetch("ATPROTO_MASTER_KEY", nil)
143
+ return Base64.strict_decode64(key) if key
144
+
145
+ # Generate and store a random key if not configured
146
+ key = SecureRandom.random_bytes(32)
147
+ warn "WARNING: Using randomly generated encryption key - tokens will not persist across restarts"
148
+ key
149
+ end
150
+
151
+ def salt_for_context(context)
152
+ OpenSSL::Digest.digest("SHA256", "atproto-salt-#{context}")
153
+ end
154
+ end
155
+ end
156
+ end
@@ -136,7 +136,7 @@ module AtprotoAuth
136
136
  raise HttpError.new("Request failed: #{e.message}", nil)
137
137
  end
138
138
 
139
- def make_post_request(uri, body, headers = {}, redirect_count = 0) # rubocop:disable Metrics/AbcSize
139
+ def make_post_request(uri, body, headers = {}, redirect_count = 0)
140
140
  http = Net::HTTP.new(uri.host, uri.port)
141
141
  configure_http_client!(http)
142
142
 
@@ -182,7 +182,7 @@ module AtprotoAuth
182
182
  # - headers: Hash of headers from the original request
183
183
  # - redirect_count: Number of redirects so far
184
184
  # - body: Request body for POST requests
185
- def handle_redirect(**kwargs) # rubocop:disable Metrics/AbcSize
185
+ def handle_redirect(**kwargs)
186
186
  response = kwargs[:response]
187
187
  redirect_count = kwargs[:redirect_count]
188
188
 
@@ -78,7 +78,7 @@ module AtprotoAuth
78
78
  raise DocumentError, "Invalid DID format (must be did:plc:): #{did}"
79
79
  end
80
80
 
81
- def validate_services!(services) # rubocop:disable Metrics/CyclomaticComplexity
81
+ def validate_services!(services)
82
82
  return if services.nil?
83
83
  raise DocumentError, "services must be an array" unless services.is_a?(Array)
84
84
 
@@ -119,7 +119,7 @@ module AtprotoAuth
119
119
  normalized.downcase
120
120
  end
121
121
 
122
- def resolve_handle_dns(handle) # rubocop:disable Metrics/CyclomaticComplexity
122
+ def resolve_handle_dns(handle)
123
123
  domain = extract_domain(handle)
124
124
  return nil unless domain
125
125