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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../configuration'
|
|
4
|
+
require_relative '../serialization/session'
|
|
5
|
+
require_relative '../storage/key_builder'
|
|
6
|
+
require_relative 'session'
|
|
7
|
+
|
|
8
|
+
module AuthProto
|
|
9
|
+
module State
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# Manages active OAuth sessions with secure persistent storage
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
class SessionManager
|
|
16
|
+
|
|
17
|
+
# Creates a session manager and its encrypted session serializer.
|
|
18
|
+
# @raise [ArgumentError] if `ATPROTO_MASTER_KEY` is not valid Base64
|
|
19
|
+
#
|
|
20
|
+
def initialize
|
|
21
|
+
@serializer = Serialization::Session.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Creates and stores a session and its state-token lookup under a storage lock.
|
|
25
|
+
#
|
|
26
|
+
# @param client_id [String] OAuth client ID
|
|
27
|
+
# @param scope [String] requested space-separated scopes
|
|
28
|
+
# @param auth_server [ServerMetadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
29
|
+
# @param did [String, nil] pre-resolved account DID
|
|
30
|
+
#
|
|
31
|
+
# @return [Session] newly created session
|
|
32
|
+
# @raise [PKCE::Error] if session PKCE generation fails
|
|
33
|
+
# @raise [Storage::StorageError] if writing or locking fails
|
|
34
|
+
# @raise [Serialization::ValidationError] if the session cannot be serialized
|
|
35
|
+
# @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
|
|
36
|
+
#
|
|
37
|
+
def create_session(client_id:, scope:, auth_server: nil, did: nil)
|
|
38
|
+
session = Session.new(
|
|
39
|
+
client_id: client_id,
|
|
40
|
+
scope: scope,
|
|
41
|
+
auth_server: auth_server,
|
|
42
|
+
did: did
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Store both session and state mapping atomically
|
|
46
|
+
session_key = Storage::KeyBuilder.session_key(session.session_id)
|
|
47
|
+
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
48
|
+
|
|
49
|
+
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
50
|
+
serialized = @serializer.serialize(session)
|
|
51
|
+
AuthProto.storage.set(session_key, serialized)
|
|
52
|
+
AuthProto.storage.set(state_key, session.session_id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
session
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Persists a session and updates its state-token lookup under a storage lock.
|
|
59
|
+
#
|
|
60
|
+
# @param session [Session] session to persist
|
|
61
|
+
# @return [Session] the supplied session
|
|
62
|
+
# @raise [Storage::StorageError] if writing or locking fails
|
|
63
|
+
# @raise [Serialization::ValidationError] if the session cannot be serialized
|
|
64
|
+
# @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
|
|
65
|
+
#
|
|
66
|
+
def update_session(session)
|
|
67
|
+
session_key = Storage::KeyBuilder.session_key(session.session_id)
|
|
68
|
+
|
|
69
|
+
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
70
|
+
serialized = @serializer.serialize(session)
|
|
71
|
+
AuthProto.storage.set(session_key, serialized)
|
|
72
|
+
|
|
73
|
+
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
74
|
+
AuthProto.storage.set(state_key, session.session_id)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
session
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Loads and decrypts a session by ID.
|
|
81
|
+
#
|
|
82
|
+
# Returns `nil` for missing, unreadable or malformed sessions and for expired
|
|
83
|
+
# sessions that have no refresh token. Read/deserialization errors are logged.
|
|
84
|
+
#
|
|
85
|
+
# @param session_id [String] session identifier
|
|
86
|
+
# @return [Session, nil] restored session, or `nil` if unavailable
|
|
87
|
+
#
|
|
88
|
+
def get_session(session_id)
|
|
89
|
+
session_key = Storage::KeyBuilder.session_key(session_id)
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
serialized = AuthProto.storage.get(session_key)
|
|
93
|
+
return nil unless serialized
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
AuthProto.configuration.logger.error("Failed to get session: #{e.message}")
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
session = @serializer.deserialize(serialized)
|
|
101
|
+
return nil if !session.renewable? && session.tokens&.expired?
|
|
102
|
+
|
|
103
|
+
session
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
AuthProto.configuration.logger.error("Failed to deserialize session: #{e.message}")
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Finds a stored session using its OAuth callback state token.
|
|
111
|
+
#
|
|
112
|
+
# Storage read errors are logged and treated as a missing session.
|
|
113
|
+
#
|
|
114
|
+
# @param state [String, nil] callback state token
|
|
115
|
+
# @return [Session, nil] restored session, or `nil` if unavailable
|
|
116
|
+
#
|
|
117
|
+
def get_session_by_state(state)
|
|
118
|
+
return nil unless state
|
|
119
|
+
|
|
120
|
+
state_key = Storage::KeyBuilder.state_key(state)
|
|
121
|
+
|
|
122
|
+
begin
|
|
123
|
+
session_id = AuthProto.storage.get(state_key)
|
|
124
|
+
return nil unless session_id
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
AuthProto.configuration.logger.error("Failed to get session by state: #{e.message}")
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
get_session(session_id)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Deletes a session and its state mapping if it can be retrieved.
|
|
134
|
+
#
|
|
135
|
+
# @param session_id [String] session identifier
|
|
136
|
+
# @return [void]
|
|
137
|
+
# @raise [Storage::StorageError] if deletion or locking fails
|
|
138
|
+
#
|
|
139
|
+
def remove_session(session_id)
|
|
140
|
+
session = get_session(session_id)
|
|
141
|
+
return unless session
|
|
142
|
+
|
|
143
|
+
session_key = Storage::KeyBuilder.session_key(session_id)
|
|
144
|
+
state_key = Storage::KeyBuilder.state_key(session.state_token)
|
|
145
|
+
|
|
146
|
+
AuthProto.storage.with_lock(session_key, ttl: 30) do
|
|
147
|
+
AuthProto.storage.delete(session_key)
|
|
148
|
+
AuthProto.storage.delete(state_key)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Provides an expiry cleanup hook; currently performs no work.
|
|
153
|
+
# Expiry is handled through backend TTLs and retrieval validation.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
#
|
|
157
|
+
def cleanup_expired
|
|
158
|
+
# No-op - expiry handled by storage TTL and retrieval validation
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
module State
|
|
5
|
+
|
|
6
|
+
#
|
|
7
|
+
# Represents a set of OAuth tokens and their associated metadata
|
|
8
|
+
#
|
|
9
|
+
|
|
10
|
+
class TokenSet
|
|
11
|
+
|
|
12
|
+
# DPoP-bound access token.
|
|
13
|
+
# @return [String]
|
|
14
|
+
attr_reader :access_token
|
|
15
|
+
|
|
16
|
+
# Refresh token, if issued.
|
|
17
|
+
# @return [String, nil]
|
|
18
|
+
attr_reader :refresh_token
|
|
19
|
+
|
|
20
|
+
# Token scheme; always `DPoP`.
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :token_type
|
|
23
|
+
|
|
24
|
+
# Space-separated granted scopes.
|
|
25
|
+
# @return [String]
|
|
26
|
+
attr_reader :scope
|
|
27
|
+
|
|
28
|
+
# Access token expiry time.
|
|
29
|
+
# @return [Time]
|
|
30
|
+
attr_reader :expires_at
|
|
31
|
+
|
|
32
|
+
# DID of the authenticated account.
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :sub
|
|
35
|
+
|
|
36
|
+
# Creates a token set and calculates its expiry from the current time.
|
|
37
|
+
#
|
|
38
|
+
# @param access_token [String] nonempty DPoP-bound access token
|
|
39
|
+
# @param token_type [String] must be `DPoP`
|
|
40
|
+
# @param expires_in [Integer] lifetime in seconds; negative values represent expired tokens
|
|
41
|
+
# @param scope [String] nonempty space-separated granted scopes
|
|
42
|
+
# @param sub [String] nonempty account DID
|
|
43
|
+
# @param refresh_token [String, nil] refresh token, if issued
|
|
44
|
+
# @raise [ArgumentError] if required strings are missing, the token type is wrong, or expiry is not an integer
|
|
45
|
+
#
|
|
46
|
+
def initialize(
|
|
47
|
+
access_token:,
|
|
48
|
+
token_type:,
|
|
49
|
+
expires_in:,
|
|
50
|
+
scope:,
|
|
51
|
+
sub:,
|
|
52
|
+
refresh_token: nil
|
|
53
|
+
)
|
|
54
|
+
validate_token_type(token_type)
|
|
55
|
+
validate_required("access_token", access_token)
|
|
56
|
+
validate_required("scope", scope)
|
|
57
|
+
validate_required("sub", sub)
|
|
58
|
+
validate_expires_in(expires_in)
|
|
59
|
+
|
|
60
|
+
@access_token = access_token
|
|
61
|
+
@refresh_token = refresh_token
|
|
62
|
+
@token_type = token_type
|
|
63
|
+
@scope = scope
|
|
64
|
+
@sub = sub
|
|
65
|
+
@expires_at = Time.now + expires_in
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether this token set includes a refresh token
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def renewable?
|
|
71
|
+
!@refresh_token.nil? && !@refresh_token.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Checks expiry, treating tokens close to expiry as already expired.
|
|
75
|
+
#
|
|
76
|
+
# @param buffer [Numeric] seconds before actual expiry to consider the token expired
|
|
77
|
+
# @return [Boolean] whether the token has expired or is within the buffer
|
|
78
|
+
#
|
|
79
|
+
def expired?(buffer = 30)
|
|
80
|
+
Time.now >= (@expires_at - buffer)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_token_type(type)
|
|
87
|
+
raise ArgumentError, "token_type must be DPoP" unless type == "DPoP"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_required(name, value)
|
|
91
|
+
raise ArgumentError, "#{name} is required" if value.nil? || value.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_expires_in(expires_in)
|
|
95
|
+
return if expires_in.is_a?(Integer)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "expires_in must be an integer"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module AuthProto
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Manages OAuth state for AT Protocol authorization flows. This module provides thread-safe
|
|
9
|
+
# storage and management of authorization session data, including tokens, PKCE values,
|
|
10
|
+
# and identity information.
|
|
11
|
+
#
|
|
12
|
+
# The module consists of three main components:
|
|
13
|
+
#
|
|
14
|
+
# 1. {TokenSet} - Represents OAuth tokens (access and refresh) with their metadata,
|
|
15
|
+
# including expiration times, scope, and the authenticated user's DID.
|
|
16
|
+
#
|
|
17
|
+
# 2. {Session} - Tracks the complete state of an authorization flow, including:
|
|
18
|
+
# - PKCE verifier/challenge pairs
|
|
19
|
+
# - State tokens for request verification
|
|
20
|
+
# - Authorization server information
|
|
21
|
+
# - Current tokens and user identity (DID)
|
|
22
|
+
#
|
|
23
|
+
# 3. {SessionManager} - Provides thread-safe storage and retrieval of active sessions,
|
|
24
|
+
# with support for lookup by session ID or state token.
|
|
25
|
+
#
|
|
26
|
+
# @example Creating and managing a session
|
|
27
|
+
# manager = AuthProto::State::SessionManager.new
|
|
28
|
+
#
|
|
29
|
+
# # Create a new session
|
|
30
|
+
# session = manager.create_session(
|
|
31
|
+
# client_id: "https://myapp.com/oauth-client-metadata.json",
|
|
32
|
+
# scope: "atproto"
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
35
|
+
# # Update session with tokens
|
|
36
|
+
# tokens = AuthProto::State::TokenSet.new(
|
|
37
|
+
# access_token: "...",
|
|
38
|
+
# token_type: "DPoP",
|
|
39
|
+
# expires_in: 3600,
|
|
40
|
+
# scope: "atproto",
|
|
41
|
+
# sub: "did:plc:abcdef123"
|
|
42
|
+
# )
|
|
43
|
+
# session.tokens = tokens
|
|
44
|
+
# manager.update_session(session)
|
|
45
|
+
#
|
|
46
|
+
# # Lookup session later
|
|
47
|
+
# session = manager.get_session(session.session_id)
|
|
48
|
+
# if session.authorized?
|
|
49
|
+
# puts "Access token: #{session.tokens.access_token}"
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# Session mutation is synchronized, and the session manager uses backend locks
|
|
53
|
+
# when persisting changes. TokenSet values themselves are read-only.
|
|
54
|
+
#
|
|
55
|
+
|
|
56
|
+
module State
|
|
57
|
+
|
|
58
|
+
# Error raised for session-related issues
|
|
59
|
+
class SessionError < AuthProto::Error; end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
5
|
+
module AuthProto
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Storage contracts and optional memory, Redis and SQLite adapters for OAuth state.
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
module Storage
|
|
12
|
+
|
|
13
|
+
autoload :Memory, File.expand_path('memory', __dir__)
|
|
14
|
+
autoload :Redis, File.expand_path('redis', __dir__)
|
|
15
|
+
autoload :SQLite, File.expand_path('sqlite', __dir__)
|
|
16
|
+
|
|
17
|
+
# Base error class for storage operations
|
|
18
|
+
class StorageError < AuthProto::Error; end
|
|
19
|
+
|
|
20
|
+
# Error for lock-related operations
|
|
21
|
+
class LockError < StorageError; end
|
|
22
|
+
|
|
23
|
+
#
|
|
24
|
+
# Base storage interface that all implementations must conform to
|
|
25
|
+
#
|
|
26
|
+
|
|
27
|
+
class Interface
|
|
28
|
+
|
|
29
|
+
# Stores a value, optionally with an expiry.
|
|
30
|
+
# Implementations must validate keys and TTLs. Accepted value types depend on the backend.
|
|
31
|
+
#
|
|
32
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
33
|
+
# @param value [Object] backend-compatible value to store
|
|
34
|
+
# @param ttl [Integer, nil] positive lifetime in seconds, or `nil` for no expiry
|
|
35
|
+
# @return [Boolean] whether the write succeeded
|
|
36
|
+
# @raise [StorageError] if validation or storage fails
|
|
37
|
+
# @raise [NotImplementedError] unless overridden by a storage adapter
|
|
38
|
+
#
|
|
39
|
+
def set(key, value, ttl: nil)
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads an unexpired value.
|
|
44
|
+
#
|
|
45
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
46
|
+
# @return [Object, nil] stored value, or `nil` if missing/expired
|
|
47
|
+
# @raise [StorageError] if validation or storage fails
|
|
48
|
+
# @raise [NotImplementedError] unless overridden by a storage adapter
|
|
49
|
+
#
|
|
50
|
+
def get(key)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Deletes a stored value.
|
|
55
|
+
#
|
|
56
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
57
|
+
# @return [Boolean] backend deletion status; see adapter-specific semantics
|
|
58
|
+
# @raise [StorageError] if validation or storage fails
|
|
59
|
+
# @raise [NotImplementedError] unless overridden by a storage adapter
|
|
60
|
+
#
|
|
61
|
+
def delete(key)
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Checks whether an unexpired key exists.
|
|
66
|
+
#
|
|
67
|
+
# @param key [String] nonempty key beginning with `atproto:`
|
|
68
|
+
# @return [Boolean] whether the key exists
|
|
69
|
+
# @raise [StorageError] if validation or storage fails
|
|
70
|
+
# @raise [NotImplementedError] unless overridden by a storage adapter
|
|
71
|
+
#
|
|
72
|
+
def exists?(key)
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs a block while holding a lock that expires after the requested TTL.
|
|
77
|
+
#
|
|
78
|
+
# A lock is not renewed automatically; choose a TTL longer than the protected work.
|
|
79
|
+
#
|
|
80
|
+
# @param key [String] nonempty lock key beginning with `atproto:`
|
|
81
|
+
# @param ttl [Integer] positive lock lifetime in seconds
|
|
82
|
+
# @yield block to run after acquiring the lock
|
|
83
|
+
# @yieldreturn [Object] result to return to the caller
|
|
84
|
+
# @return [Object] block result
|
|
85
|
+
# @raise [LockError] if the lock cannot be acquired
|
|
86
|
+
# @raise [StorageError] if validation or backend operations fail
|
|
87
|
+
# @raise [ArgumentError] if no block is supplied
|
|
88
|
+
# @raise [NotImplementedError] unless overridden by a storage adapter
|
|
89
|
+
#
|
|
90
|
+
def with_lock(key, ttl: 30)
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
protected
|
|
95
|
+
|
|
96
|
+
# Validates that a storage key is nonempty and begins with `atproto:`.
|
|
97
|
+
#
|
|
98
|
+
# @param key [String] value to validate
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [StorageError] if the value is invalid
|
|
101
|
+
#
|
|
102
|
+
def validate_key(key)
|
|
103
|
+
raise StorageError, "Key cannot be nil" if key.nil?
|
|
104
|
+
raise StorageError, "Key must be a string" unless key.is_a?(String)
|
|
105
|
+
raise StorageError, "Key cannot be empty" if key.empty?
|
|
106
|
+
raise StorageError, "Invalid key format" unless key.start_with?("atproto:")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Validates that a TTL is `nil` or a positive integer.
|
|
110
|
+
#
|
|
111
|
+
# @param ttl [Integer, nil] value to validate
|
|
112
|
+
# @return [void]
|
|
113
|
+
# @raise [StorageError] if the value is invalid
|
|
114
|
+
#
|
|
115
|
+
def validate_ttl(ttl)
|
|
116
|
+
return if ttl.nil?
|
|
117
|
+
raise StorageError, "TTL must be a positive integer" unless ttl.is_a?(Integer) && ttl.positive?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
module Storage
|
|
5
|
+
|
|
6
|
+
#
|
|
7
|
+
# Utility for building storage keys with correct format
|
|
8
|
+
#
|
|
9
|
+
|
|
10
|
+
class KeyBuilder
|
|
11
|
+
|
|
12
|
+
# Separator between storage key components.
|
|
13
|
+
# @return [String]
|
|
14
|
+
NAMESPACE_SEPARATOR = ":"
|
|
15
|
+
|
|
16
|
+
# Root namespace for every generated storage key.
|
|
17
|
+
# @return [String]
|
|
18
|
+
NAMESPACE_PREFIX = "atproto"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
|
|
22
|
+
# Builds a namespaced storage key for a session.
|
|
23
|
+
#
|
|
24
|
+
# @param id [String] session ID
|
|
25
|
+
# @return [String] colon-separated key beginning with `atproto:`
|
|
26
|
+
#
|
|
27
|
+
def session_key(id)
|
|
28
|
+
build_key("session", id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds a namespaced storage key for a state-token lookup.
|
|
32
|
+
#
|
|
33
|
+
# @param token [String] OAuth state token
|
|
34
|
+
# @return [String] colon-separated key beginning with `atproto:`
|
|
35
|
+
#
|
|
36
|
+
def state_key(token)
|
|
37
|
+
build_key("state", token)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Builds a namespaced storage key for a DPoP nonce.
|
|
41
|
+
#
|
|
42
|
+
# @param server_url [String] server origin URL
|
|
43
|
+
# @return [String] colon-separated key beginning with `atproto:`
|
|
44
|
+
#
|
|
45
|
+
def nonce_key(server_url)
|
|
46
|
+
build_key("nonce", server_url)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Builds a namespaced storage key for a DPoP key.
|
|
50
|
+
#
|
|
51
|
+
# @param client_id [String] OAuth client ID
|
|
52
|
+
# @return [String] colon-separated key beginning with `atproto:`
|
|
53
|
+
#
|
|
54
|
+
def dpop_key(client_id)
|
|
55
|
+
build_key("dpop", client_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Builds a namespaced storage key for a lock.
|
|
59
|
+
#
|
|
60
|
+
# @param namespace [String] lock namespace, such as `session`
|
|
61
|
+
# @param id [String] resource identifier
|
|
62
|
+
# @return [String] colon-separated key beginning with `atproto:`
|
|
63
|
+
#
|
|
64
|
+
def lock_key(namespace, id)
|
|
65
|
+
build_key("lock", namespace, id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def build_key(*parts)
|
|
71
|
+
[NAMESPACE_PREFIX, *parts].join(NAMESPACE_SEPARATOR)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|