authproto 0.3.0.beta1
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/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- metadata +200 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
5
|
+
require_relative '../configuration'
|
|
6
|
+
require_relative 'interface'
|
|
7
|
+
|
|
8
|
+
module AuthProto
|
|
9
|
+
module Storage
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# Thread-safe in-memory storage implementation
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
class Memory < Interface
|
|
16
|
+
|
|
17
|
+
# Creates an empty in-memory store and starts a background expiry-cleanup thread.
|
|
18
|
+
#
|
|
19
|
+
# Data and locks are local to this instance and are lost on process exit.
|
|
20
|
+
#
|
|
21
|
+
def initialize
|
|
22
|
+
super
|
|
23
|
+
@data = {}
|
|
24
|
+
@locks = {}
|
|
25
|
+
@expirations = {}
|
|
26
|
+
@monitor = Monitor.new
|
|
27
|
+
@cleanup_interval = 60 # 1 minute
|
|
28
|
+
start_cleanup_thread
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stores a value in Memory storage.
|
|
32
|
+
#
|
|
33
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
34
|
+
# @param value [Object] value retained by reference
|
|
35
|
+
# @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
|
|
36
|
+
# @return [true] if the write succeeds
|
|
37
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
38
|
+
#
|
|
39
|
+
def set(key, value, ttl: nil)
|
|
40
|
+
validate_key(key)
|
|
41
|
+
validate_ttl(ttl)
|
|
42
|
+
|
|
43
|
+
@monitor.synchronize do
|
|
44
|
+
@data[key] = value
|
|
45
|
+
set_expiration(key, ttl) if ttl
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Retrieves an unexpired value from Memory storage.
|
|
51
|
+
#
|
|
52
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
53
|
+
# @return [Object, nil] stored value, or `nil` if missing/expired
|
|
54
|
+
# @raise [StorageError] if the key is invalid
|
|
55
|
+
#
|
|
56
|
+
def get(key)
|
|
57
|
+
validate_key(key)
|
|
58
|
+
|
|
59
|
+
@monitor.synchronize do
|
|
60
|
+
return nil if expired?(key)
|
|
61
|
+
|
|
62
|
+
@data[key]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deletes a key from Memory storage.
|
|
67
|
+
#
|
|
68
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
69
|
+
# @return [true] `true`, including when the key was absent
|
|
70
|
+
# @raise [StorageError] if the key is invalid
|
|
71
|
+
#
|
|
72
|
+
def delete(key)
|
|
73
|
+
validate_key(key)
|
|
74
|
+
|
|
75
|
+
@monitor.synchronize do
|
|
76
|
+
@data.delete(key)
|
|
77
|
+
@expirations.delete(key)
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Checks for an unexpired key in Memory storage.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
85
|
+
# @return [Boolean] whether the key exists and has not expired
|
|
86
|
+
# @raise [StorageError] if the key is invalid
|
|
87
|
+
#
|
|
88
|
+
def exists?(key)
|
|
89
|
+
validate_key(key)
|
|
90
|
+
|
|
91
|
+
@monitor.synchronize do
|
|
92
|
+
return false unless @data.key?(key)
|
|
93
|
+
return false if expired?(key)
|
|
94
|
+
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Acquires a Memory lock, yields, and releases the lock when the block exits.
|
|
100
|
+
#
|
|
101
|
+
# Locks expire after `ttl` seconds and are not renewed while the block runs.
|
|
102
|
+
# Exceptions from the block propagate to the caller.
|
|
103
|
+
#
|
|
104
|
+
# @param key [String] nonempty lock key beginning with `atproto:`
|
|
105
|
+
# @param ttl [Integer] positive lock lifetime in seconds
|
|
106
|
+
#
|
|
107
|
+
# @yield work to protect with the lock
|
|
108
|
+
# @yieldreturn [Object] value returned to the caller
|
|
109
|
+
# @return [Object] block result
|
|
110
|
+
#
|
|
111
|
+
# @raise [LockError] if another owner holds the lock
|
|
112
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
113
|
+
# @raise [ArgumentError] if no block is supplied
|
|
114
|
+
#
|
|
115
|
+
def with_lock(key, ttl: 30)
|
|
116
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
117
|
+
|
|
118
|
+
acquired = acquire_lock(key, ttl: ttl)
|
|
119
|
+
raise LockError, "Failed to acquire lock" unless acquired
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
yield
|
|
123
|
+
ensure
|
|
124
|
+
release_lock(key)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Clears all values, expiration records and locks from this store.
|
|
129
|
+
# @return [void]
|
|
130
|
+
def clear
|
|
131
|
+
@monitor.synchronize do
|
|
132
|
+
@data.clear
|
|
133
|
+
@expirations.clear
|
|
134
|
+
@locks.clear
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def acquire_lock(key, ttl:)
|
|
141
|
+
validate_key(key)
|
|
142
|
+
validate_ttl(ttl)
|
|
143
|
+
lock_key = "lock:#{key}"
|
|
144
|
+
|
|
145
|
+
@monitor.synchronize do
|
|
146
|
+
return false if @locks[lock_key] && !lock_expired?(lock_key)
|
|
147
|
+
|
|
148
|
+
@locks[lock_key] = Time.now.to_i + ttl
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def release_lock(key)
|
|
154
|
+
validate_key(key)
|
|
155
|
+
lock_key = "lock:#{key}"
|
|
156
|
+
|
|
157
|
+
@monitor.synchronize do
|
|
158
|
+
@locks.delete(lock_key)
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def expired?(key)
|
|
164
|
+
return false unless @expirations.key?(key)
|
|
165
|
+
|
|
166
|
+
expiry = @expirations[key]
|
|
167
|
+
if Time.now.to_i >= expiry
|
|
168
|
+
@data.delete(key)
|
|
169
|
+
@expirations.delete(key)
|
|
170
|
+
true
|
|
171
|
+
else
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def lock_expired?(lock_key)
|
|
177
|
+
expiry = @locks[lock_key]
|
|
178
|
+
return false unless expiry
|
|
179
|
+
|
|
180
|
+
if Time.now.to_i >= expiry
|
|
181
|
+
@locks.delete(lock_key)
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def set_expiration(key, ttl)
|
|
189
|
+
@expirations[key] = Time.now.to_i + ttl
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cleanup_expired
|
|
193
|
+
@monitor.synchronize do
|
|
194
|
+
now = Time.now.to_i
|
|
195
|
+
|
|
196
|
+
# Cleanup expired data
|
|
197
|
+
@expirations.each do |key, expiry|
|
|
198
|
+
if now >= expiry
|
|
199
|
+
@data.delete(key)
|
|
200
|
+
@expirations.delete(key)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Cleanup expired locks
|
|
205
|
+
@locks.delete_if { |key, expiry| now >= expiry }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def start_cleanup_thread
|
|
210
|
+
Thread.new do
|
|
211
|
+
loop do
|
|
212
|
+
sleep @cleanup_interval
|
|
213
|
+
cleanup_expired
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
# Log error but keep thread running
|
|
216
|
+
AuthProto.configuration.logger.error "Storage cleanup error: #{e.message}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'interface'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'redis'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
warn "Error: missing Redis adapter. Add this to your Gemfile:"
|
|
9
|
+
warn " gem 'redis', '~> 5.4'"
|
|
10
|
+
raise
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module AuthProto
|
|
14
|
+
module Storage
|
|
15
|
+
|
|
16
|
+
#
|
|
17
|
+
# Redis storage implementation
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
class Redis < Interface
|
|
21
|
+
|
|
22
|
+
# Error raised for Redis-specific issues
|
|
23
|
+
class RedisError < StorageError; end
|
|
24
|
+
|
|
25
|
+
# Creates a Redis adapter; connections are opened lazily by the Redis client.
|
|
26
|
+
#
|
|
27
|
+
# Requires the optional `redis` gem. Values are stored using Redis string semantics.
|
|
28
|
+
#
|
|
29
|
+
# @param redis_client [::Redis, nil] existing Redis connection, or `nil` for `Redis.new`
|
|
30
|
+
#
|
|
31
|
+
def initialize(redis_client: nil)
|
|
32
|
+
super()
|
|
33
|
+
@redis_client = redis_client || ::Redis.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Stores a value in Redis storage.
|
|
37
|
+
#
|
|
38
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
39
|
+
# @param value [#to_s, nil] value converted to Redis string data; `nil` is stored as an empty string
|
|
40
|
+
# @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
|
|
41
|
+
#
|
|
42
|
+
# @return [true] if the write succeeds
|
|
43
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
44
|
+
# @raise [RedisError] if the backend operation fails
|
|
45
|
+
#
|
|
46
|
+
def set(key, value, ttl: nil)
|
|
47
|
+
validate_key(key)
|
|
48
|
+
validate_ttl(ttl)
|
|
49
|
+
|
|
50
|
+
@redis_client.set(key, value, ex: ttl)
|
|
51
|
+
true
|
|
52
|
+
rescue ::Redis::BaseError => e
|
|
53
|
+
raise RedisError, "Failed to set value: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Retrieves an unexpired value from Redis storage.
|
|
57
|
+
#
|
|
58
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
59
|
+
# @return [String, nil] stored value, or `nil` if missing/expired; empty Redis strings also return `nil`
|
|
60
|
+
# @raise [StorageError] if the key is invalid
|
|
61
|
+
# @raise [RedisError] if the backend operation fails
|
|
62
|
+
#
|
|
63
|
+
def get(key)
|
|
64
|
+
validate_key(key)
|
|
65
|
+
|
|
66
|
+
value = @redis_client.get(key)
|
|
67
|
+
value.nil? || value == "" ? nil : value
|
|
68
|
+
rescue ::Redis::BaseError => e
|
|
69
|
+
raise RedisError, "Failed to get value: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deletes a key from Redis storage.
|
|
73
|
+
#
|
|
74
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
75
|
+
# @return [Boolean] whether a stored row/key was deleted
|
|
76
|
+
# @raise [StorageError] if the key is invalid
|
|
77
|
+
# @raise [RedisError] if the backend operation fails
|
|
78
|
+
#
|
|
79
|
+
def delete(key)
|
|
80
|
+
validate_key(key)
|
|
81
|
+
|
|
82
|
+
@redis_client.del(key).positive?
|
|
83
|
+
rescue ::Redis::BaseError => e
|
|
84
|
+
raise RedisError, "Failed to delete value: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Checks for an unexpired key in Redis storage.
|
|
88
|
+
#
|
|
89
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
90
|
+
# @return [Boolean] whether the key exists and has not expired
|
|
91
|
+
# @raise [StorageError] if the key is invalid
|
|
92
|
+
# @raise [RedisError] if the backend operation fails
|
|
93
|
+
#
|
|
94
|
+
def exists?(key)
|
|
95
|
+
validate_key(key)
|
|
96
|
+
|
|
97
|
+
@redis_client.exists?(key)
|
|
98
|
+
rescue ::Redis::BaseError => e
|
|
99
|
+
raise RedisError, "Failed to check existence: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Acquires a Redis lock, yields, and releases the lock when the block exits.
|
|
103
|
+
#
|
|
104
|
+
# Locks expire after `ttl` seconds and are not renewed while the block runs.
|
|
105
|
+
# Exceptions from the block propagate to the caller.
|
|
106
|
+
#
|
|
107
|
+
# @param key [String] nonempty lock key beginning with `atproto:`
|
|
108
|
+
# @param ttl [Integer] positive lock lifetime in seconds
|
|
109
|
+
#
|
|
110
|
+
# @yield work to protect with the lock
|
|
111
|
+
# @yieldreturn [Object] value returned to the caller
|
|
112
|
+
# @return [Object] block result
|
|
113
|
+
#
|
|
114
|
+
# @raise [LockError] if another owner holds the lock
|
|
115
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
116
|
+
# @raise [ArgumentError] if no block is supplied
|
|
117
|
+
# @raise [RedisError] if the backend operation fails
|
|
118
|
+
#
|
|
119
|
+
def with_lock(key, ttl: 30)
|
|
120
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
121
|
+
|
|
122
|
+
acquired = acquire_lock(key, ttl: ttl)
|
|
123
|
+
raise LockError, "Failed to acquire lock" unless acquired
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
yield
|
|
127
|
+
ensure
|
|
128
|
+
release_lock(key)
|
|
129
|
+
end
|
|
130
|
+
rescue ::Redis::BaseError => e
|
|
131
|
+
raise RedisError, "Lock operation failed: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def acquire_lock(key, ttl:)
|
|
137
|
+
validate_key(key)
|
|
138
|
+
validate_ttl(ttl)
|
|
139
|
+
|
|
140
|
+
lock_key = "atproto:locks:#{key}"
|
|
141
|
+
@redis_client.set(lock_key, Time.now.to_i, nx: true, ex: ttl)
|
|
142
|
+
rescue ::Redis::BaseError => e
|
|
143
|
+
raise RedisError, "Failed to acquire lock: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def release_lock(key)
|
|
147
|
+
validate_key(key)
|
|
148
|
+
|
|
149
|
+
lock_key = "atproto:locks:#{key}"
|
|
150
|
+
@redis_client.del(lock_key).positive?
|
|
151
|
+
rescue ::Redis::BaseError => e
|
|
152
|
+
raise RedisError, "Failed to release lock: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
require_relative 'interface'
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require 'sqlite3'
|
|
10
|
+
rescue LoadError
|
|
11
|
+
warn "Error: missing SQLite adapter. Add this to your Gemfile:"
|
|
12
|
+
warn " gem 'sqlite3', '~> 2.9'"
|
|
13
|
+
raise
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module AuthProto
|
|
17
|
+
module Storage
|
|
18
|
+
|
|
19
|
+
#
|
|
20
|
+
# SQLite storage implementation
|
|
21
|
+
#
|
|
22
|
+
|
|
23
|
+
class SQLite < Interface
|
|
24
|
+
|
|
25
|
+
# Error raised for SQLite-specific issues
|
|
26
|
+
class SQLiteError < StorageError; end
|
|
27
|
+
|
|
28
|
+
# Default SQLite lock-wait timeout in milliseconds.
|
|
29
|
+
# @return [Integer]
|
|
30
|
+
DEFAULT_BUSY_TIMEOUT = 5_000
|
|
31
|
+
|
|
32
|
+
# Opens a SQLite store, creating parent directories and tables as needed.
|
|
33
|
+
#
|
|
34
|
+
# Enables WAL mode and full synchronous writes. Requires the optional `sqlite3` gem.
|
|
35
|
+
#
|
|
36
|
+
# @param path [String] database file path
|
|
37
|
+
# @param busy_timeout [Integer] nonnegative SQLite lock-wait timeout in milliseconds
|
|
38
|
+
# @raise [ArgumentError] if `busy_timeout` is not a nonnegative integer
|
|
39
|
+
# @raise [SQLiteError] if opening or configuring the database fails
|
|
40
|
+
# @raise [SystemCallError] if creating a parent directory fails
|
|
41
|
+
#
|
|
42
|
+
def initialize(path:, busy_timeout: DEFAULT_BUSY_TIMEOUT)
|
|
43
|
+
super()
|
|
44
|
+
|
|
45
|
+
unless busy_timeout.is_a?(Integer) && busy_timeout >= 0
|
|
46
|
+
raise ArgumentError, "Busy timeout must be a non-negative integer"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
50
|
+
|
|
51
|
+
@database = SQLite3::Database.new(path)
|
|
52
|
+
@database.busy_timeout = busy_timeout
|
|
53
|
+
|
|
54
|
+
configure_database
|
|
55
|
+
create_schema
|
|
56
|
+
rescue SQLite3::Exception => e
|
|
57
|
+
raise SQLiteError, "Failed to initialize database: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stores a value in SQLite storage.
|
|
61
|
+
#
|
|
62
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
63
|
+
# @param value [String, Numeric, nil] SQLite-compatible scalar value; strings are used for serialized OAuth data
|
|
64
|
+
# @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
|
|
65
|
+
#
|
|
66
|
+
# @return [true] if the write succeeds
|
|
67
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
68
|
+
# @raise [SQLiteError] if the backend operation fails
|
|
69
|
+
#
|
|
70
|
+
def set(key, value, ttl: nil)
|
|
71
|
+
validate_key(key)
|
|
72
|
+
validate_ttl(ttl)
|
|
73
|
+
|
|
74
|
+
expires_at = ttl && (current_time + ttl)
|
|
75
|
+
|
|
76
|
+
@database.execute(
|
|
77
|
+
<<~SQL,
|
|
78
|
+
INSERT INTO atproto_storage (key, value, expires_at)
|
|
79
|
+
VALUES (?, ?, ?)
|
|
80
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at
|
|
81
|
+
SQL
|
|
82
|
+
[key, value, expires_at]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
rescue SQLite3::Exception => e
|
|
87
|
+
raise SQLiteError, "Failed to set value: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Retrieves an unexpired value from SQLite storage.
|
|
91
|
+
#
|
|
92
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
93
|
+
# @return [String, Numeric, nil] stored value, or `nil` if missing/expired
|
|
94
|
+
# @raise [StorageError] if the key is invalid
|
|
95
|
+
# @raise [SQLiteError] if the backend operation fails
|
|
96
|
+
#
|
|
97
|
+
def get(key)
|
|
98
|
+
validate_key(key)
|
|
99
|
+
|
|
100
|
+
now = current_time
|
|
101
|
+
row = @database.get_first_row(
|
|
102
|
+
<<~SQL,
|
|
103
|
+
SELECT value
|
|
104
|
+
FROM atproto_storage
|
|
105
|
+
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
106
|
+
SQL
|
|
107
|
+
[key, now]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if row
|
|
111
|
+
row.first
|
|
112
|
+
else
|
|
113
|
+
delete_expired_value(key, now)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
rescue SQLite3::Exception => e
|
|
117
|
+
raise SQLiteError, "Failed to get value: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Deletes a key from SQLite storage.
|
|
121
|
+
#
|
|
122
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
123
|
+
# @return [Boolean] whether a stored row/key was deleted
|
|
124
|
+
# @raise [StorageError] if the key is invalid
|
|
125
|
+
# @raise [SQLiteError] if the backend operation fails
|
|
126
|
+
#
|
|
127
|
+
def delete(key)
|
|
128
|
+
validate_key(key)
|
|
129
|
+
|
|
130
|
+
result = @database.get_first_value(
|
|
131
|
+
"DELETE FROM atproto_storage WHERE key = ? RETURNING 1",
|
|
132
|
+
[key]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
!result.nil?
|
|
136
|
+
rescue SQLite3::Exception => e
|
|
137
|
+
raise SQLiteError, "Failed to delete value: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Checks for an unexpired key in SQLite storage.
|
|
141
|
+
#
|
|
142
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
143
|
+
# @return [Boolean] whether the key exists and has not expired
|
|
144
|
+
# @raise [StorageError] if the key is invalid
|
|
145
|
+
# @raise [SQLiteError] if the backend operation fails
|
|
146
|
+
#
|
|
147
|
+
def exists?(key)
|
|
148
|
+
validate_key(key)
|
|
149
|
+
|
|
150
|
+
now = current_time
|
|
151
|
+
|
|
152
|
+
exists = @database.get_first_value(
|
|
153
|
+
<<~SQL,
|
|
154
|
+
SELECT 1
|
|
155
|
+
FROM atproto_storage
|
|
156
|
+
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
157
|
+
SQL
|
|
158
|
+
[key, now]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if exists
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
delete_expired_value(key, now)
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
rescue SQLite3::Exception => e
|
|
168
|
+
raise SQLiteError, "Failed to check existence: #{e.message}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Acquires a SQLite lock, yields, and releases the lock when the block exits.
|
|
172
|
+
#
|
|
173
|
+
# Locks expire after `ttl` seconds and are not renewed while the block runs.
|
|
174
|
+
# Exceptions from the block propagate to the caller.
|
|
175
|
+
#
|
|
176
|
+
# @param key [String] nonempty lock key beginning with `atproto:`
|
|
177
|
+
# @param ttl [Integer] positive lock lifetime in seconds
|
|
178
|
+
#
|
|
179
|
+
# @yield work to protect with the lock
|
|
180
|
+
# @yieldreturn [Object] value returned to the caller
|
|
181
|
+
# @return [Object] block result
|
|
182
|
+
#
|
|
183
|
+
# @raise [LockError] if another owner holds the lock
|
|
184
|
+
# @raise [StorageError] if key or TTL validation fails
|
|
185
|
+
# @raise [ArgumentError] if no block is supplied
|
|
186
|
+
# @raise [SQLiteError] if the backend operation fails
|
|
187
|
+
#
|
|
188
|
+
def with_lock(key, ttl: 30)
|
|
189
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
190
|
+
|
|
191
|
+
owner = acquire_lock(key, ttl: ttl)
|
|
192
|
+
raise LockError, "Failed to acquire lock" unless owner
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
yield
|
|
196
|
+
ensure
|
|
197
|
+
release_lock(key, owner)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Closes the underlying SQLite connection.
|
|
202
|
+
#
|
|
203
|
+
# @return [void]
|
|
204
|
+
# @raise [SQLiteError] if closing the database fails
|
|
205
|
+
#
|
|
206
|
+
def close
|
|
207
|
+
@database.close
|
|
208
|
+
rescue SQLite3::Exception => e
|
|
209
|
+
raise SQLiteError, "Failed to close database: #{e.message}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def configure_database
|
|
215
|
+
@database.execute("PRAGMA journal_mode = WAL")
|
|
216
|
+
@database.execute("PRAGMA synchronous = FULL")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def create_schema
|
|
220
|
+
@database.execute_batch(<<~SQL)
|
|
221
|
+
CREATE TABLE IF NOT EXISTS atproto_storage (
|
|
222
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
223
|
+
value,
|
|
224
|
+
expires_at REAL
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
CREATE TABLE IF NOT EXISTS atproto_locks (
|
|
228
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
229
|
+
owner TEXT NOT NULL,
|
|
230
|
+
expires_at REAL NOT NULL
|
|
231
|
+
);
|
|
232
|
+
SQL
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def acquire_lock(key, ttl:)
|
|
236
|
+
validate_key(key)
|
|
237
|
+
validate_ttl(ttl)
|
|
238
|
+
|
|
239
|
+
now = current_time
|
|
240
|
+
owner = SecureRandom.uuid
|
|
241
|
+
|
|
242
|
+
acquired_owner = @database.get_first_value(
|
|
243
|
+
<<~SQL,
|
|
244
|
+
INSERT INTO atproto_locks (key, owner, expires_at)
|
|
245
|
+
VALUES (?, ?, ?)
|
|
246
|
+
ON CONFLICT(key) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
|
|
247
|
+
WHERE atproto_locks.expires_at <= ?
|
|
248
|
+
RETURNING owner
|
|
249
|
+
SQL
|
|
250
|
+
[key, owner, now + ttl, now]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
acquired_owner ? owner : nil
|
|
254
|
+
rescue SQLite3::Exception => e
|
|
255
|
+
raise SQLiteError, "Failed to acquire lock: #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def release_lock(key, owner)
|
|
259
|
+
result = @database.get_first_value(
|
|
260
|
+
"DELETE FROM atproto_locks WHERE key = ? AND owner = ? RETURNING 1",
|
|
261
|
+
[key, owner]
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
!result.nil?
|
|
265
|
+
rescue SQLite3::Exception => e
|
|
266
|
+
raise SQLiteError, "Failed to release lock: #{e.message}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def delete_expired_value(key, now)
|
|
270
|
+
@database.execute(
|
|
271
|
+
"DELETE FROM atproto_storage WHERE key = ? AND expires_at <= ?",
|
|
272
|
+
[key, now]
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def current_time
|
|
277
|
+
Time.now.to_f
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|