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.
- 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
|