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.
- checksums.yaml +4 -4
- data/.rubocop.yml +17 -2
- data/CHANGELOG.md +23 -2
- data/README.md +91 -5
- data/examples/confidential_client/.gitignore +2 -0
- data/examples/confidential_client/Gemfile +1 -0
- data/examples/confidential_client/Gemfile.lock +10 -1
- data/examples/confidential_client/README.md +86 -9
- data/examples/confidential_client/app.rb +83 -12
- data/examples/confidential_client/{public/client-metadata.json → config/client-metadata.example.json} +5 -4
- 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 +0 -0
- data/examples/confidential_client/views/authorized.erb +1 -1
- data/lib/atproto_auth/client.rb +98 -38
- data/lib/atproto_auth/client_metadata.rb +2 -2
- data/lib/atproto_auth/configuration.rb +35 -1
- data/lib/atproto_auth/dpop/key_manager.rb +1 -1
- data/lib/atproto_auth/dpop/nonce_manager.rb +30 -47
- data/lib/atproto_auth/encryption.rb +156 -0
- data/lib/atproto_auth/http_client.rb +2 -2
- data/lib/atproto_auth/identity/document.rb +1 -1
- data/lib/atproto_auth/identity/resolver.rb +1 -1
- data/lib/atproto_auth/serialization/base.rb +189 -0
- data/lib/atproto_auth/serialization/dpop_key.rb +29 -0
- data/lib/atproto_auth/serialization/session.rb +77 -0
- data/lib/atproto_auth/serialization/stored_nonce.rb +37 -0
- data/lib/atproto_auth/serialization/token_set.rb +43 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +20 -1
- data/lib/atproto_auth/state/session_manager.rb +67 -20
- data/lib/atproto_auth/storage/interface.rb +112 -0
- data/lib/atproto_auth/storage/key_builder.rb +39 -0
- data/lib/atproto_auth/storage/memory.rb +191 -0
- data/lib/atproto_auth/storage/redis.rb +119 -0
- data/lib/atproto_auth/token/refresh.rb +249 -0
- data/lib/atproto_auth/version.rb +1 -1
- data/lib/atproto_auth.rb +29 -1
- metadata +32 -5
- 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)
|
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
|
-
|
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
|
-
|
33
|
-
|
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
|
-
|
44
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
62
|
-
|
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
|
-
|
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
|