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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. 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