atproto_auth 0.0.1 → 0.1.1

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.
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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Serialization
5
+ class Error < AtprotoAuth::Error; end
6
+
7
+ class VersionError < Error; end
8
+
9
+ class TypeMismatchError < Error; end
10
+
11
+ class ValidationError < Error; end
12
+
13
+ # Base serializer that all type-specific serializers inherit from
14
+ class Base
15
+ CURRENT_VERSION = 1
16
+ SENSITIVE_FIELDS = [
17
+ "access_token",
18
+ "refresh_token",
19
+ "private_key",
20
+ "d", # EC private key component
21
+ "pkce_verifier"
22
+ ].freeze
23
+
24
+ class << self
25
+ # Serialize object to storage format
26
+ # @param obj [Object] Object to serialize
27
+ # @return [String] JSON serialized data
28
+ def serialize(obj)
29
+ new.serialize(obj)
30
+ end
31
+
32
+ # Deserialize from storage format
33
+ # @param data [String] JSON serialized data
34
+ # @return [Object] Deserialized object
35
+ def deserialize(data)
36
+ new.deserialize(data)
37
+ end
38
+ end
39
+
40
+ def initialize
41
+ @encryption = Encryption::Service.new
42
+ end
43
+
44
+ # Serialize object to storage format
45
+ # @param obj [Object] Object to serialize
46
+ # @return [String] JSON serialized data
47
+ def serialize(obj)
48
+ validate_object!(obj)
49
+
50
+ # First serialize the object
51
+ data = {
52
+ version: CURRENT_VERSION,
53
+ type: type_identifier,
54
+ created_at: Time.now.utc.iso8601,
55
+ updated_at: Time.now.utc.iso8601,
56
+ data: serialize_data(obj)
57
+ }
58
+
59
+ # Then encrypt sensitive fields
60
+ encrypt_sensitive_fields!(data)
61
+
62
+ # Finally convert to JSON
63
+ JSON.generate(data)
64
+ end
65
+
66
+ # Deserialize from storage format
67
+ # @param data [String] JSON serialized data
68
+ # @return [Object] Deserialized object
69
+ def deserialize(data)
70
+ parsed = parse_json(data)
71
+ validate_serialized_data!(parsed)
72
+
73
+ # Decrypt sensitive fields
74
+ decrypt_sensitive_fields!(parsed)
75
+
76
+ # Deserialize according to version
77
+ deserialize_version(parsed)
78
+ end
79
+
80
+ private
81
+
82
+ def encrypt_sensitive_fields!(data, path: [])
83
+ return unless data.is_a?(Hash)
84
+
85
+ data.each do |key, value|
86
+ current_path = path + [key]
87
+
88
+ if sensitive_field?(key)
89
+ data[key] = @encryption.encrypt(
90
+ value.to_s,
91
+ context: current_path.join(".")
92
+ )
93
+ elsif value.is_a?(Hash)
94
+ encrypt_sensitive_fields!(value, path: current_path)
95
+ elsif value.is_a?(Array)
96
+ value.each_with_index do |v, i|
97
+ encrypt_sensitive_fields!(v, path: current_path + [i.to_s]) if v.is_a?(Hash)
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ def decrypt_sensitive_fields!(data, path: [])
104
+ return unless data.is_a?(Hash)
105
+
106
+ data.each do |key, value|
107
+ current_path = path + [key]
108
+ if sensitive_field?(key)
109
+ data[key] = @encryption.decrypt(
110
+ value.transform_keys(&:to_sym),
111
+ context: current_path.join(".")
112
+ )
113
+ elsif value.is_a?(Hash)
114
+ decrypt_sensitive_fields!(value, path: current_path)
115
+ elsif value.is_a?(Array)
116
+ value.each_with_index do |v, i|
117
+ decrypt_sensitive_fields!(v, path: current_path + [i.to_s]) if v.is_a?(Hash)
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ def sensitive_field?(field)
124
+ SENSITIVE_FIELDS.any? do |sensitive_field|
125
+ field.to_s == sensitive_field
126
+ end
127
+ end
128
+
129
+ # Type identifier for serialized data
130
+ # @return [String]
131
+ def type_identifier
132
+ raise NotImplementedError
133
+ end
134
+
135
+ # Serialize object to hash
136
+ # @param obj [Object] Object to serialize
137
+ # @return [Hash] Serialized data
138
+ def serialize_data(_obj)
139
+ raise NotImplementedError
140
+ end
141
+
142
+ # Deserialize object from hash
143
+ # @param data [Hash] Serialized data
144
+ # @return [Object] Deserialized object
145
+ def deserialize_data(_data)
146
+ raise NotImplementedError
147
+ end
148
+
149
+ # Validate object before serialization
150
+ # @param obj [Object] Object to validate
151
+ # @raise [ValidationError] if object is invalid
152
+ def validate_object!(_obj)
153
+ raise NotImplementedError
154
+ end
155
+
156
+ def parse_json(data)
157
+ JSON.parse(data)
158
+ rescue JSON::ParserError => e
159
+ raise Error, "Invalid JSON data: #{e.message}"
160
+ end
161
+
162
+ def validate_serialized_data!(data)
163
+ raise Error, "Invalid serialized data format" unless data.is_a?(Hash)
164
+
165
+ unless data["type"] == type_identifier
166
+ raise TypeMismatchError,
167
+ "Expected type #{type_identifier}, got #{data["type"]}"
168
+ end
169
+
170
+ raise VersionError, "Invalid version format" unless data["version"].is_a?(Integer)
171
+
172
+ return unless data["version"] > CURRENT_VERSION
173
+
174
+ raise VersionError,
175
+ "Version #{data["version"]} not supported"
176
+ end
177
+
178
+ def deserialize_version(data)
179
+ case data["version"]
180
+ when 1
181
+ deserialize_data(data["data"])
182
+ else
183
+ raise VersionError,
184
+ "Unknown version: #{data["version"]}"
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Serialization
5
+ # Handles serialization of DPoP keys
6
+ class DPoPKey < Base
7
+ def type_identifier
8
+ "DPoPKey"
9
+ end
10
+
11
+ private
12
+
13
+ def validate_object!(obj)
14
+ return if obj.is_a?(AtprotoAuth::DPoP::KeyManager)
15
+
16
+ raise ValidationError,
17
+ "Expected KeyManager object, got #{obj.class}"
18
+ end
19
+
20
+ def serialize_data(obj)
21
+ obj.to_jwk(include_private: true).to_h
22
+ end
23
+
24
+ def deserialize_data(data)
25
+ AtprotoAuth::DPoP::KeyManager.from_jwk(data)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Serialization
5
+ # Handles serialization of Session objects
6
+ class Session < Base
7
+ def type_identifier
8
+ "Session"
9
+ end
10
+
11
+ private
12
+
13
+ def validate_object!(obj)
14
+ return if obj.is_a?(AtprotoAuth::State::Session)
15
+
16
+ raise ValidationError,
17
+ "Expected Session object, got #{obj.class}"
18
+ end
19
+
20
+ def serialize_data(obj)
21
+ {
22
+ session_id: obj.session_id,
23
+ state_token: obj.state_token,
24
+ client_id: obj.client_id,
25
+ scope: obj.scope,
26
+ pkce_verifier: obj.pkce_verifier,
27
+ pkce_challenge: obj.pkce_challenge,
28
+ did: obj.did,
29
+ tokens: serialize_token_set(obj.tokens),
30
+ auth_server: serialize_auth_server(obj.auth_server)
31
+ }
32
+ end
33
+
34
+ def deserialize_data(data)
35
+ AtprotoAuth::State::Session.new(
36
+ client_id: data["client_id"],
37
+ scope: data["scope"],
38
+ did: data["did"],
39
+ auth_server: deserialize_auth_server(data["auth_server"])
40
+ ).tap do |session|
41
+ # Set readonly attributes
42
+ session.instance_variable_set(:@session_id, data["session_id"])
43
+ session.instance_variable_set(:@state_token, data["state_token"])
44
+ session.instance_variable_set(:@pkce_verifier, data["pkce_verifier"])
45
+ session.instance_variable_set(:@pkce_challenge, data["pkce_challenge"])
46
+
47
+ # Set tokens if present
48
+ session.tokens = deserialize_token_set(data["tokens"]) if data["tokens"]
49
+ end
50
+ end
51
+
52
+ def serialize_token_set(tokens)
53
+ return nil unless tokens
54
+
55
+ TokenSet.new.serialize(tokens)
56
+ end
57
+
58
+ def deserialize_token_set(data)
59
+ return nil unless data
60
+
61
+ TokenSet.new.deserialize(data)
62
+ end
63
+
64
+ def serialize_auth_server(server)
65
+ return nil unless server
66
+
67
+ server.to_h
68
+ end
69
+
70
+ def deserialize_auth_server(data)
71
+ return nil unless data
72
+
73
+ AtprotoAuth::ServerMetadata::AuthorizationServer.new(data)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Serialization
5
+ # Handles serialization of StoredNonce objects
6
+ class StoredNonce < Base
7
+ def type_identifier
8
+ "StoredNonce"
9
+ end
10
+
11
+ private
12
+
13
+ def validate_object!(obj)
14
+ return if obj.is_a?(AtprotoAuth::DPoP::NonceManager::StoredNonce)
15
+
16
+ raise ValidationError,
17
+ "Expected StoredNonce object, got #{obj.class}"
18
+ end
19
+
20
+ def serialize_data(obj)
21
+ {
22
+ value: obj.value,
23
+ server_url: obj.server_url,
24
+ timestamp: obj.timestamp
25
+ }
26
+ end
27
+
28
+ def deserialize_data(data)
29
+ AtprotoAuth::DPoP::NonceManager::StoredNonce.new(
30
+ data["value"],
31
+ data["server_url"],
32
+ timestamp: Time.at(data["timestamp"])
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Serialization
5
+ # Handles serialization of TokenSet objects
6
+ class TokenSet < Base
7
+ def type_identifier
8
+ "TokenSet"
9
+ end
10
+
11
+ private
12
+
13
+ def validate_object!(obj)
14
+ return if obj.is_a?(AtprotoAuth::State::TokenSet)
15
+
16
+ raise ValidationError,
17
+ "Expected TokenSet object, got #{obj.class}"
18
+ end
19
+
20
+ def serialize_data(obj)
21
+ {
22
+ access_token: obj.access_token,
23
+ refresh_token: obj.refresh_token,
24
+ token_type: obj.token_type,
25
+ expires_at: obj.expires_at.utc.iso8601,
26
+ scope: obj.scope,
27
+ sub: obj.sub
28
+ }
29
+ end
30
+
31
+ def deserialize_data(data)
32
+ AtprotoAuth::State::TokenSet.new(
33
+ access_token: data["access_token"],
34
+ refresh_token: data["refresh_token"],
35
+ token_type: data["token_type"],
36
+ expires_in: (Time.parse(data["expires_at"]) - Time.now).to_i,
37
+ scope: data["scope"],
38
+ sub: data["sub"]
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -57,9 +57,28 @@ module AtprotoAuth
57
57
  new(metadata)
58
58
  end
59
59
 
60
+ def to_h
61
+ {
62
+ issuer: issuer,
63
+ authorization_endpoint: authorization_endpoint,
64
+ token_endpoint: token_endpoint,
65
+ pushed_authorization_request_endpoint: pushed_authorization_request_endpoint,
66
+ response_types_supported: response_types_supported,
67
+ grant_types_supported: grant_types_supported,
68
+ code_challenge_methods_supported: code_challenge_methods_supported,
69
+ token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
70
+ token_endpoint_auth_signing_alg_values_supported: token_endpoint_auth_signing_alg_values_supported,
71
+ scopes_supported: scopes_supported,
72
+ dpop_signing_alg_values_supported: dpop_signing_alg_values_supported,
73
+ authorization_response_iss_parameter_supported: true,
74
+ require_pushed_authorization_requests: true,
75
+ client_id_metadata_document_supported: true
76
+ }
77
+ end
78
+
60
79
  private
61
80
 
62
- def validate_and_set_metadata!(metadata) # rubocop:disable Metrics/AbcSize
81
+ def validate_and_set_metadata!(metadata)
63
82
  REQUIRED_FIELDS.each do |field|
64
83
  raise InvalidAuthorizationServer, "#{field} is required" unless metadata[field]
65
84
  end
@@ -1,18 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "time"
5
- require "monitor"
6
-
7
3
  module AtprotoAuth
8
4
  module State
9
- # Manages active OAuth sessions
5
+ # Manages active OAuth sessions with secure persistent storage
10
6
  class SessionManager
11
- include MonitorMixin
12
-
13
7
  def initialize
14
- super # Initialize MonitorMixin
15
- @sessions = {}
8
+ @serializer = Serialization::Session.new
16
9
  end
17
10
 
18
11
  # Creates and stores a new session
@@ -29,8 +22,31 @@ module AtprotoAuth
29
22
  did: did
30
23
  )
31
24
 
32
- synchronize do
33
- @sessions[session.session_id] = session
25
+ # Store both session and state mapping atomically
26
+ session_key = Storage::KeyBuilder.session_key(session.session_id)
27
+ state_key = Storage::KeyBuilder.state_key(session.state_token)
28
+
29
+ AtprotoAuth.storage.with_lock(session_key, ttl: 30) do
30
+ serialized = @serializer.serialize(session)
31
+ AtprotoAuth.storage.set(session_key, serialized)
32
+ AtprotoAuth.storage.set(state_key, session.session_id)
33
+ end
34
+
35
+ session
36
+ end
37
+
38
+ # Updates an existing session
39
+ # @return [Session] The session to update
40
+ # @return [Session] The updated session
41
+ def update_session(session)
42
+ session_key = Storage::KeyBuilder.session_key(session.session_id)
43
+
44
+ AtprotoAuth.storage.with_lock(session_key, ttl: 30) do
45
+ serialized = @serializer.serialize(session)
46
+ AtprotoAuth.storage.set(session_key, serialized)
47
+
48
+ state_key = Storage::KeyBuilder.state_key(session.state_token)
49
+ AtprotoAuth.storage.set(state_key, session.session_id)
34
50
  end
35
51
 
36
52
  session
@@ -40,8 +56,24 @@ module AtprotoAuth
40
56
  # @param session_id [String] Session ID to look up
41
57
  # @return [Session, nil] The session if found
42
58
  def get_session(session_id)
43
- synchronize do
44
- @sessions[session_id]
59
+ session_key = Storage::KeyBuilder.session_key(session_id)
60
+
61
+ begin
62
+ serialized = AtprotoAuth.storage.get(session_key)
63
+ return nil unless serialized
64
+ rescue StandardError => e
65
+ AtprotoAuth.configuration.logger.error("Failed to get session: #{e.message}")
66
+ return nil
67
+ end
68
+
69
+ begin
70
+ session = @serializer.deserialize(serialized)
71
+ return nil if !session.renewable? && session.tokens&.expired?
72
+
73
+ session
74
+ rescue StandardError => e
75
+ AtprotoAuth.configuration.logger.error("Failed to deserialize session: #{e.message}")
76
+ nil
45
77
  end
46
78
  end
47
79
 
@@ -49,26 +81,41 @@ module AtprotoAuth
49
81
  # @param state [String] State token to look up
50
82
  # @return [Session, nil] The session if found
51
83
  def get_session_by_state(state)
52
- synchronize do
53
- @sessions.values.find { |session| session.validate_state(state) }
84
+ return nil unless state
85
+
86
+ state_key = Storage::KeyBuilder.state_key(state)
87
+
88
+ begin
89
+ session_id = AtprotoAuth.storage.get(state_key)
90
+ return nil unless session_id
91
+ rescue StandardError => e
92
+ AtprotoAuth.configuration.logger.error("Failed to get session by state: #{e.message}")
93
+ return nil
54
94
  end
95
+
96
+ get_session(session_id)
55
97
  end
56
98
 
57
99
  # Removes a session
58
100
  # @param session_id [String] Session ID to remove
59
101
  # @return [void]
60
102
  def remove_session(session_id)
61
- synchronize do
62
- @sessions.delete(session_id)
103
+ session = get_session(session_id)
104
+ return unless session
105
+
106
+ session_key = Storage::KeyBuilder.session_key(session_id)
107
+ state_key = Storage::KeyBuilder.state_key(session.state_token)
108
+
109
+ AtprotoAuth.storage.with_lock(session_key, ttl: 30) do
110
+ AtprotoAuth.storage.delete(session_key)
111
+ AtprotoAuth.storage.delete(state_key)
63
112
  end
64
113
  end
65
114
 
66
115
  # Removes all expired sessions
67
116
  # @return [void]
68
117
  def cleanup_expired
69
- synchronize do
70
- @sessions.delete_if { |_, session| !session.renewable? && session.tokens&.expired? }
71
- end
118
+ # No-op - expiry handled by storage TTL and retrieval validation
72
119
  end
73
120
  end
74
121
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Storage
5
+ # Base storage interface that all implementations must conform to
6
+ class Interface
7
+ # Store a value with optional TTL
8
+ # @param key [String] Storage key
9
+ # @param value [Object] Value to store
10
+ # @param ttl [Integer, nil] Time-to-live in seconds
11
+ # @return [Boolean] Success status
12
+ # @raise [StorageError] if operation fails
13
+ def set(key, value, ttl: nil)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ # Retrieve a value
18
+ # @param key [String] Storage key
19
+ # @return [Object, nil] Stored value or nil if not found
20
+ # @raise [StorageError] if operation fails
21
+ def get(key)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # Delete a value
26
+ # @param key [String] Storage key
27
+ # @return [Boolean] Success status
28
+ # @raise [StorageError] if operation fails
29
+ def delete(key)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # Check if key exists
34
+ # @param key [String] Storage key
35
+ # @return [Boolean] True if key exists
36
+ # @raise [StorageError] if operation fails
37
+ def exists?(key)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # Get multiple values
42
+ # @param keys [Array<String>] Storage keys
43
+ # @return [Hash<String, Object>] Key-value pairs
44
+ # @raise [StorageError] if operation fails
45
+ def multi_get(keys)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ # Store multiple values
50
+ # @param hash [Hash<String, Object>] Key-value pairs
51
+ # @param ttl [Integer, nil] Time-to-live in seconds
52
+ # @return [Boolean] Success status
53
+ # @raise [StorageError] if operation fails
54
+ def multi_set(hash, ttl: nil)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ # Acquire a lock
59
+ # @param key [String] Lock key
60
+ # @param ttl [Integer] Lock timeout in seconds
61
+ # @return [Boolean] True if lock acquired
62
+ # @raise [StorageError] if operation fails
63
+ def acquire_lock(key, ttl:)
64
+ raise NotImplementedError
65
+ end
66
+
67
+ # Release a lock
68
+ # @param key [String] Lock key
69
+ # @return [Boolean] Success status
70
+ # @raise [StorageError] if operation fails
71
+ def release_lock(key)
72
+ raise NotImplementedError
73
+ end
74
+
75
+ # Execute block with lock
76
+ # @param key [String] Lock key
77
+ # @param ttl [Integer] Lock timeout in seconds
78
+ # @yield Block to execute with lock
79
+ # @return [Object] Block result
80
+ # @raise [StorageError] if operation fails
81
+ def with_lock(key, ttl: 30)
82
+ raise NotImplementedError
83
+ end
84
+
85
+ protected
86
+
87
+ # Validate key format
88
+ # @param key [String] Key to validate
89
+ # @raise [StorageError] if key is invalid
90
+ def validate_key!(key)
91
+ raise StorageError, "Key cannot be nil" if key.nil?
92
+ raise StorageError, "Key must be a string" unless key.is_a?(String)
93
+ raise StorageError, "Key cannot be empty" if key.empty?
94
+ raise StorageError, "Invalid key format" unless key.start_with?("atproto:")
95
+ end
96
+
97
+ # Validate TTL value
98
+ # @param ttl [Integer, nil] TTL to validate
99
+ # @raise [StorageError] if TTL is invalid
100
+ def validate_ttl!(ttl)
101
+ return if ttl.nil?
102
+ raise StorageError, "TTL must be a positive integer" unless ttl.is_a?(Integer) && ttl.positive?
103
+ end
104
+ end
105
+
106
+ # Base error class for storage operations
107
+ class StorageError < AtprotoAuth::Error; end
108
+
109
+ # Error for lock-related operations
110
+ class LockError < StorageError; end
111
+ end
112
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module Storage
5
+ # Utility for building storage keys with correct format
6
+ class KeyBuilder
7
+ NAMESPACE_SEPARATOR = ":"
8
+ NAMESPACE_PREFIX = "atproto"
9
+
10
+ class << self
11
+ def session_key(id)
12
+ build_key("session", id)
13
+ end
14
+
15
+ def state_key(token)
16
+ build_key("state", token)
17
+ end
18
+
19
+ def nonce_key(server_url)
20
+ build_key("nonce", server_url)
21
+ end
22
+
23
+ def dpop_key(client_id)
24
+ build_key("dpop", client_id)
25
+ end
26
+
27
+ def lock_key(namespace, id)
28
+ build_key("lock", namespace, id)
29
+ end
30
+
31
+ private
32
+
33
+ def build_key(*parts)
34
+ [NAMESPACE_PREFIX, *parts].join(NAMESPACE_SEPARATOR)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end