atproto_auth 0.0.1 → 0.1.0
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/PROJECT_STRUCTURE.txt +10129 -0
- data/README.md +88 -2
- data/examples/confidential_client/.gitignore +2 -0
- data/examples/confidential_client/Gemfile.lock +6 -0
- 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 -4
- data/examples/confidential_client/config/client-metadata.json +0 -25
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "monitor"
|
4
|
+
|
5
|
+
module AtprotoAuth
|
6
|
+
module Storage
|
7
|
+
# Thread-safe in-memory storage implementation
|
8
|
+
class Memory < Interface
|
9
|
+
def initialize
|
10
|
+
super
|
11
|
+
@data = {}
|
12
|
+
@locks = {}
|
13
|
+
@expirations = {}
|
14
|
+
@monitor = Monitor.new
|
15
|
+
@cleanup_interval = 60 # 1 minute
|
16
|
+
start_cleanup_thread
|
17
|
+
end
|
18
|
+
|
19
|
+
def set(key, value, ttl: nil)
|
20
|
+
validate_key!(key)
|
21
|
+
validate_ttl!(ttl)
|
22
|
+
|
23
|
+
@monitor.synchronize do
|
24
|
+
@data[key] = value
|
25
|
+
set_expiration(key, ttl) if ttl
|
26
|
+
true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def get(key)
|
31
|
+
validate_key!(key)
|
32
|
+
|
33
|
+
@monitor.synchronize do
|
34
|
+
return nil if expired?(key)
|
35
|
+
|
36
|
+
@data[key]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def delete(key)
|
41
|
+
validate_key!(key)
|
42
|
+
|
43
|
+
@monitor.synchronize do
|
44
|
+
@data.delete(key)
|
45
|
+
@expirations.delete(key)
|
46
|
+
true
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def exists?(key)
|
51
|
+
validate_key!(key)
|
52
|
+
|
53
|
+
@monitor.synchronize do
|
54
|
+
return false unless @data.key?(key)
|
55
|
+
return false if expired?(key)
|
56
|
+
|
57
|
+
true
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def multi_get(keys)
|
62
|
+
keys.each { |key| validate_key!(key) }
|
63
|
+
|
64
|
+
@monitor.synchronize do
|
65
|
+
result = {}
|
66
|
+
keys.each do |key|
|
67
|
+
result[key] = @data[key] if @data.key?(key) && !expired?(key)
|
68
|
+
end
|
69
|
+
result
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def multi_set(hash, ttl: nil)
|
74
|
+
hash.each_key { |key| validate_key!(key) }
|
75
|
+
validate_ttl!(ttl)
|
76
|
+
|
77
|
+
@monitor.synchronize do
|
78
|
+
hash.each do |key, value|
|
79
|
+
@data[key] = value
|
80
|
+
set_expiration(key, ttl) if ttl
|
81
|
+
end
|
82
|
+
true
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def acquire_lock(key, ttl:)
|
87
|
+
validate_key!(key)
|
88
|
+
validate_ttl!(ttl)
|
89
|
+
lock_key = "lock:#{key}"
|
90
|
+
|
91
|
+
@monitor.synchronize do
|
92
|
+
return false if @locks[lock_key] && !lock_expired?(lock_key)
|
93
|
+
|
94
|
+
@locks[lock_key] = Time.now.to_i + ttl
|
95
|
+
true
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def release_lock(key)
|
100
|
+
validate_key!(key)
|
101
|
+
lock_key = "lock:#{key}"
|
102
|
+
|
103
|
+
@monitor.synchronize do
|
104
|
+
@locks.delete(lock_key)
|
105
|
+
true
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def with_lock(key, ttl: 30)
|
110
|
+
raise ArgumentError, "Block required" unless block_given?
|
111
|
+
|
112
|
+
acquired = acquire_lock(key, ttl: ttl)
|
113
|
+
raise LockError, "Failed to acquire lock" unless acquired
|
114
|
+
|
115
|
+
begin
|
116
|
+
yield
|
117
|
+
ensure
|
118
|
+
release_lock(key)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def clear
|
123
|
+
@monitor.synchronize do
|
124
|
+
@data.clear
|
125
|
+
@expirations.clear
|
126
|
+
@locks.clear
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
def expired?(key)
|
133
|
+
return false unless @expirations.key?(key)
|
134
|
+
|
135
|
+
expiry = @expirations[key]
|
136
|
+
if Time.now.to_i >= expiry
|
137
|
+
@data.delete(key)
|
138
|
+
@expirations.delete(key)
|
139
|
+
true
|
140
|
+
else
|
141
|
+
false
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def lock_expired?(lock_key)
|
146
|
+
expiry = @locks[lock_key]
|
147
|
+
return false unless expiry
|
148
|
+
|
149
|
+
if Time.now.to_i >= expiry
|
150
|
+
@locks.delete(lock_key)
|
151
|
+
true
|
152
|
+
else
|
153
|
+
false
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def set_expiration(key, ttl)
|
158
|
+
@expirations[key] = Time.now.to_i + ttl
|
159
|
+
end
|
160
|
+
|
161
|
+
def cleanup_expired
|
162
|
+
@monitor.synchronize do
|
163
|
+
now = Time.now.to_i
|
164
|
+
|
165
|
+
# Cleanup expired data
|
166
|
+
@expirations.each do |key, expiry|
|
167
|
+
if now >= expiry
|
168
|
+
@data.delete(key)
|
169
|
+
@expirations.delete(key)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Cleanup expired locks
|
174
|
+
@locks.delete_if { |_, expiry| now >= expiry }
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def start_cleanup_thread
|
179
|
+
Thread.new do
|
180
|
+
loop do
|
181
|
+
sleep @cleanup_interval
|
182
|
+
cleanup_expired
|
183
|
+
rescue StandardError => e
|
184
|
+
# Log error but keep thread running
|
185
|
+
AtprotoAuth.configuration.logger.error "Storage cleanup error: #{e.message}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "redis"
|
4
|
+
|
5
|
+
module AtprotoAuth
|
6
|
+
module Storage
|
7
|
+
# Redis storage implementation
|
8
|
+
class Redis < Interface
|
9
|
+
# Error raised for Redis-specific issues
|
10
|
+
class RedisError < StorageError; end
|
11
|
+
|
12
|
+
def initialize(redis_client: nil)
|
13
|
+
super()
|
14
|
+
@redis_client = redis_client || ::Redis.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def set(key, value, ttl: nil)
|
18
|
+
validate_key!(key)
|
19
|
+
validate_ttl!(ttl) if ttl
|
20
|
+
|
21
|
+
@redis_client.set(key, value, ex: ttl)
|
22
|
+
true
|
23
|
+
rescue ::Redis::BaseError => e
|
24
|
+
raise RedisError, "Failed to set value: #{e.message}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def get(key)
|
28
|
+
validate_key!(key)
|
29
|
+
|
30
|
+
value = @redis_client.get(key)
|
31
|
+
value.nil? || value == "" ? nil : value
|
32
|
+
rescue ::Redis::BaseError => e
|
33
|
+
raise RedisError, "Failed to get value: #{e.message}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def delete(key)
|
37
|
+
validate_key!(key)
|
38
|
+
|
39
|
+
@redis_client.del(key).positive?
|
40
|
+
rescue ::Redis::BaseError => e
|
41
|
+
raise RedisError, "Failed to delete value: #{e.message}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def exists?(key)
|
45
|
+
validate_key!(key)
|
46
|
+
|
47
|
+
@redis_client.exists?(key)
|
48
|
+
rescue ::Redis::BaseError => e
|
49
|
+
raise RedisError, "Failed to check existence: #{e.message}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def multi_get(keys)
|
53
|
+
keys.each { |key| validate_key!(key) }
|
54
|
+
|
55
|
+
values = @redis_client.mget(keys)
|
56
|
+
result = {}
|
57
|
+
|
58
|
+
# Only include non-nil values in result hash
|
59
|
+
keys.zip(values).each do |key, value|
|
60
|
+
next if value.nil? || value == ""
|
61
|
+
|
62
|
+
result[key] = value
|
63
|
+
end
|
64
|
+
|
65
|
+
result
|
66
|
+
rescue ::Redis::BaseError => e
|
67
|
+
raise RedisError, "Failed to get multiple values: #{e.message}"
|
68
|
+
end
|
69
|
+
|
70
|
+
def multi_set(hash, ttl: nil)
|
71
|
+
hash.each_key { |key| validate_key!(key) }
|
72
|
+
validate_ttl!(ttl) if ttl
|
73
|
+
|
74
|
+
@redis_client.multi do |tx|
|
75
|
+
hash.each do |key, value|
|
76
|
+
tx.set(key, value, ex: ttl)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
true
|
80
|
+
rescue ::Redis::BaseError => e
|
81
|
+
raise RedisError, "Failed to set multiple values: #{e.message}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def acquire_lock(key, ttl:)
|
85
|
+
validate_key!(key)
|
86
|
+
validate_ttl!(ttl)
|
87
|
+
|
88
|
+
lock_key = "atproto:locks:#{key}"
|
89
|
+
@redis_client.set(lock_key, Time.now.to_i, nx: true, ex: ttl)
|
90
|
+
rescue ::Redis::BaseError => e
|
91
|
+
raise RedisError, "Failed to acquire lock: #{e.message}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def release_lock(key)
|
95
|
+
validate_key!(key)
|
96
|
+
|
97
|
+
lock_key = "atproto:locks:#{key}"
|
98
|
+
@redis_client.del(lock_key).positive?
|
99
|
+
rescue ::Redis::BaseError => e
|
100
|
+
raise RedisError, "Failed to release lock: #{e.message}"
|
101
|
+
end
|
102
|
+
|
103
|
+
def with_lock(key, ttl: 30)
|
104
|
+
raise ArgumentError, "Block required" unless block_given?
|
105
|
+
|
106
|
+
acquired = acquire_lock(key, ttl: ttl)
|
107
|
+
raise LockError, "Failed to acquire lock" unless acquired
|
108
|
+
|
109
|
+
begin
|
110
|
+
yield
|
111
|
+
ensure
|
112
|
+
release_lock(key)
|
113
|
+
end
|
114
|
+
rescue ::Redis::BaseError => e
|
115
|
+
raise RedisError, "Lock operation failed: #{e.message}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,249 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module Token
|
5
|
+
# Base error class for token-related errors
|
6
|
+
class Error < AtprotoAuth::Error
|
7
|
+
attr_reader :token_type, :error_code, :retry_possible
|
8
|
+
|
9
|
+
def initialize(message, token_type:, error_code:, retry_possible: false)
|
10
|
+
@token_type = token_type
|
11
|
+
@error_code = error_code
|
12
|
+
@retry_possible = retry_possible
|
13
|
+
super(message)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Specific token error types
|
18
|
+
class ExpiredTokenError < Error
|
19
|
+
def initialize(token_type:)
|
20
|
+
super(
|
21
|
+
"Token has expired",
|
22
|
+
token_type: token_type,
|
23
|
+
error_code: "token_expired",
|
24
|
+
retry_possible: true
|
25
|
+
)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Raised when a token is structurally valid but has been invalidated or revoked
|
30
|
+
class InvalidTokenError < Error
|
31
|
+
def initialize(token_type:)
|
32
|
+
super(
|
33
|
+
"Token is invalid",
|
34
|
+
token_type: token_type,
|
35
|
+
error_code: "token_invalid",
|
36
|
+
retry_possible: false
|
37
|
+
)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Raised during token refresh operations, includes retry information and server responses
|
42
|
+
class RefreshError < Error
|
43
|
+
def initialize(message, retry_possible: true)
|
44
|
+
super(
|
45
|
+
message,
|
46
|
+
token_type: "refresh",
|
47
|
+
error_code: "refresh_failed",
|
48
|
+
retry_possible: retry_possible
|
49
|
+
)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Handles token refresh operations with retry logic
|
54
|
+
class Refresh
|
55
|
+
include MonitorMixin
|
56
|
+
|
57
|
+
# Maximum number of refresh attempts
|
58
|
+
MAX_RETRIES = 3
|
59
|
+
# Base delay between retries in seconds
|
60
|
+
BASE_DELAY = 1
|
61
|
+
# Maximum delay between retries in seconds
|
62
|
+
MAX_DELAY = 8
|
63
|
+
|
64
|
+
attr_reader :session, :dpop_client, :auth_server, :client_metadata
|
65
|
+
|
66
|
+
def initialize(session:, dpop_client:, auth_server:, client_metadata:)
|
67
|
+
super() # Initialize MonitorMixin
|
68
|
+
@session = session
|
69
|
+
@dpop_client = dpop_client
|
70
|
+
@auth_server = auth_server
|
71
|
+
@attempt_count = 0
|
72
|
+
@client_metadata = client_metadata
|
73
|
+
end
|
74
|
+
|
75
|
+
# Performs token refresh with retry logic
|
76
|
+
# @return [TokenSet] New token set
|
77
|
+
# @raise [RefreshError] if refresh fails
|
78
|
+
def perform!
|
79
|
+
synchronize do
|
80
|
+
raise RefreshError.new("No refresh token available", retry_possible: false) unless session.renewable?
|
81
|
+
raise RefreshError.new("No access token to refresh", retry_possible: false) if session.tokens.nil?
|
82
|
+
|
83
|
+
with_retries do
|
84
|
+
request_token_refresh
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def with_retries
|
92
|
+
@attempt_count = 0
|
93
|
+
last_error = nil
|
94
|
+
|
95
|
+
while @attempt_count < MAX_RETRIES
|
96
|
+
begin
|
97
|
+
return yield
|
98
|
+
rescue StandardError => e
|
99
|
+
last_error = e
|
100
|
+
@attempt_count += 1
|
101
|
+
|
102
|
+
# Don't retry if error indicates retry not possible
|
103
|
+
raise e if e.respond_to?(:retry_possible) && !e.retry_possible
|
104
|
+
|
105
|
+
sleep calculate_delay if @attempt_count < MAX_RETRIES
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Reached max retries
|
110
|
+
raise RefreshError.new(
|
111
|
+
"Token refresh failed after #{MAX_RETRIES} attempts: #{last_error.message}",
|
112
|
+
retry_possible: false
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
def calculate_delay
|
117
|
+
# Exponential backoff with jitter
|
118
|
+
delay = [BASE_DELAY * (2**(@attempt_count - 1)), MAX_DELAY].min
|
119
|
+
delay + (rand * 0.5 * delay) # Add up to 50% jitter
|
120
|
+
end
|
121
|
+
|
122
|
+
def request_token_refresh
|
123
|
+
# Generate DPoP proof for token request
|
124
|
+
proof = dpop_client.generate_proof(
|
125
|
+
http_method: "POST",
|
126
|
+
http_uri: auth_server.token_endpoint
|
127
|
+
)
|
128
|
+
|
129
|
+
# Build refresh request
|
130
|
+
body = {
|
131
|
+
grant_type: "refresh_token",
|
132
|
+
refresh_token: session.tokens.refresh_token,
|
133
|
+
scope: session.scope
|
134
|
+
}
|
135
|
+
|
136
|
+
# Add client authentication if available
|
137
|
+
add_client_authentication!(body) if client_metadata.confidential?
|
138
|
+
|
139
|
+
# Make request
|
140
|
+
response = AtprotoAuth.configuration.http_client.post(
|
141
|
+
auth_server.token_endpoint,
|
142
|
+
body: body,
|
143
|
+
headers: {
|
144
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
145
|
+
"DPoP" => proof
|
146
|
+
}
|
147
|
+
)
|
148
|
+
|
149
|
+
handle_refresh_response(response)
|
150
|
+
end
|
151
|
+
|
152
|
+
def add_client_authentication!(body)
|
153
|
+
return unless session.client_metadata.jwks && !session.client_metadata.jwks["keys"].empty?
|
154
|
+
|
155
|
+
signing_key = JOSE::JWK.from_map(session.client_metadata.jwks["keys"].first)
|
156
|
+
client_assertion = PAR::ClientAssertion.new(
|
157
|
+
client_id: session.client_metadata.client_id,
|
158
|
+
signing_key: signing_key
|
159
|
+
)
|
160
|
+
|
161
|
+
body.merge!(
|
162
|
+
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
163
|
+
client_assertion: client_assertion.generate_jwt(
|
164
|
+
audience: auth_server.issuer
|
165
|
+
)
|
166
|
+
)
|
167
|
+
end
|
168
|
+
|
169
|
+
def handle_refresh_response(response)
|
170
|
+
case response[:status]
|
171
|
+
when 200
|
172
|
+
process_successful_response(response)
|
173
|
+
when 400
|
174
|
+
handle_400_response(response)
|
175
|
+
when 401
|
176
|
+
raise RefreshError.new("Refresh token is invalid", retry_possible: false)
|
177
|
+
when 429
|
178
|
+
handle_rate_limit_response(response)
|
179
|
+
else
|
180
|
+
raise RefreshError, "Unexpected response: #{response[:status]}"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def process_successful_response(response)
|
185
|
+
data = JSON.parse(response[:body])
|
186
|
+
validate_refresh_response!(data)
|
187
|
+
|
188
|
+
AtprotoAuth::State::TokenSet.new(
|
189
|
+
access_token: data["access_token"],
|
190
|
+
token_type: data["token_type"],
|
191
|
+
expires_in: data["expires_in"],
|
192
|
+
refresh_token: data["refresh_token"],
|
193
|
+
scope: data["scope"],
|
194
|
+
sub: data["sub"]
|
195
|
+
)
|
196
|
+
rescue JSON::ParserError => e
|
197
|
+
raise RefreshError, "Invalid response format: #{e.message}"
|
198
|
+
end
|
199
|
+
|
200
|
+
def handle_400_response(response)
|
201
|
+
error_data = JSON.parse(response[:body])
|
202
|
+
error_description = error_data["error_description"] || error_data["error"]
|
203
|
+
|
204
|
+
# Handle DPoP nonce requirement
|
205
|
+
if error_data["error"] == "use_dpop_nonce"
|
206
|
+
dpop_client.process_response(response[:headers], auth_server.issuer)
|
207
|
+
raise RefreshError, "Retry with DPoP nonce"
|
208
|
+
end
|
209
|
+
|
210
|
+
raise RefreshError.new(
|
211
|
+
"Refresh request failed: #{error_description}",
|
212
|
+
retry_possible: false
|
213
|
+
)
|
214
|
+
rescue JSON::ParserError
|
215
|
+
raise RefreshError, "Invalid error response format"
|
216
|
+
end
|
217
|
+
|
218
|
+
def handle_rate_limit_response(response)
|
219
|
+
# Extract retry-after if available
|
220
|
+
retry_after = response[:headers]["Retry-After"]&.to_i || calculate_delay
|
221
|
+
raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
|
222
|
+
end
|
223
|
+
|
224
|
+
def validate_refresh_response!(data)
|
225
|
+
# Required fields
|
226
|
+
%w[access_token token_type expires_in scope sub].each do |field|
|
227
|
+
raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
|
228
|
+
end
|
229
|
+
|
230
|
+
# Token type must be DPoP
|
231
|
+
unless data["token_type"] == "DPoP"
|
232
|
+
raise RefreshError.new("Invalid token_type: #{data["token_type"]}", retry_possible: false)
|
233
|
+
end
|
234
|
+
|
235
|
+
# Scope must include original scopes
|
236
|
+
original_scopes = session.scope.split
|
237
|
+
response_scopes = data["scope"].split
|
238
|
+
unless (original_scopes - response_scopes).empty?
|
239
|
+
raise RefreshError.new("Invalid scope in response", retry_possible: false)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Subject must match
|
243
|
+
return if data["sub"] == session.tokens.sub
|
244
|
+
|
245
|
+
raise RefreshError.new("Subject mismatch in response", retry_possible: false)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
data/lib/atproto_auth/version.rb
CHANGED
data/lib/atproto_auth.rb
CHANGED
@@ -5,12 +5,18 @@ require "jwt"
|
|
5
5
|
|
6
6
|
require "atproto_auth/version"
|
7
7
|
|
8
|
-
require "atproto_auth/configuration"
|
9
8
|
require "atproto_auth/errors"
|
9
|
+
require "atproto_auth/configuration"
|
10
|
+
require "atproto_auth/encryption"
|
10
11
|
require "atproto_auth/client_metadata"
|
11
12
|
require "atproto_auth/http_client"
|
12
13
|
require "atproto_auth/pkce"
|
13
14
|
|
15
|
+
require "atproto_auth/storage/interface"
|
16
|
+
require "atproto_auth/storage/key_builder"
|
17
|
+
require "atproto_auth/storage/memory"
|
18
|
+
require "atproto_auth/storage/redis"
|
19
|
+
|
14
20
|
require "atproto_auth/server_metadata"
|
15
21
|
require "atproto_auth/server_metadata/origin_url"
|
16
22
|
require "atproto_auth/server_metadata/authorization_server"
|
@@ -36,6 +42,14 @@ require "atproto_auth/par/request"
|
|
36
42
|
require "atproto_auth/par/response"
|
37
43
|
require "atproto_auth/par/client"
|
38
44
|
|
45
|
+
require "atproto_auth/serialization/base"
|
46
|
+
require "atproto_auth/serialization/dpop_key"
|
47
|
+
require "atproto_auth/serialization/session"
|
48
|
+
require "atproto_auth/serialization/stored_nonce"
|
49
|
+
require "atproto_auth/serialization/token_set"
|
50
|
+
|
51
|
+
require "atproto_auth/token/refresh"
|
52
|
+
|
39
53
|
require "atproto_auth/client"
|
40
54
|
|
41
55
|
# AtprotoAuth is a Ruby library implementing the AT Protocol OAuth specification.
|
@@ -51,6 +65,20 @@ module AtprotoAuth
|
|
51
65
|
|
52
66
|
def configure
|
53
67
|
yield(configuration)
|
68
|
+
configuration.validate!
|
69
|
+
configuration
|
70
|
+
end
|
71
|
+
|
72
|
+
# Gets the configured storage backend
|
73
|
+
# @return [Storage::Interface] The configured storage implementation
|
74
|
+
def storage
|
75
|
+
configuration.storage
|
76
|
+
end
|
77
|
+
|
78
|
+
# Resets the configuration to defaults
|
79
|
+
# Primarily used in testing
|
80
|
+
def reset_configuration!
|
81
|
+
@configuration = Configuration.new
|
54
82
|
end
|
55
83
|
end
|
56
84
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: atproto_auth
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Josh Huckabee
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-12-
|
11
|
+
date: 2024-12-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jose
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '2.9'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: redis
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.3'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.3'
|
41
55
|
description: A Ruby library for implementing AT Protocol OAuth flows, including DPoP,
|
42
56
|
PAR, and dynamic client registration. Supports both client and server-side implementations
|
43
57
|
with comprehensive security features.
|
@@ -50,16 +64,19 @@ files:
|
|
50
64
|
- ".rubocop.yml"
|
51
65
|
- CHANGELOG.md
|
52
66
|
- LICENSE.txt
|
67
|
+
- PROJECT_STRUCTURE.txt
|
53
68
|
- README.md
|
54
69
|
- Rakefile
|
70
|
+
- examples/confidential_client/.gitignore
|
55
71
|
- examples/confidential_client/Gemfile
|
56
72
|
- examples/confidential_client/Gemfile.lock
|
57
73
|
- examples/confidential_client/README.md
|
58
74
|
- examples/confidential_client/app.rb
|
59
75
|
- examples/confidential_client/config.ru
|
60
|
-
- examples/confidential_client/config/client-metadata.json
|
61
|
-
- examples/confidential_client/public/client-metadata.json
|
76
|
+
- examples/confidential_client/config/client-metadata.example.json
|
62
77
|
- examples/confidential_client/public/styles.css
|
78
|
+
- examples/confidential_client/screenshots/screenshot-1-sign-in.png
|
79
|
+
- examples/confidential_client/screenshots/screenshot-2-success.png
|
63
80
|
- examples/confidential_client/scripts/generate_keys.rb
|
64
81
|
- examples/confidential_client/views/authorized.erb
|
65
82
|
- examples/confidential_client/views/index.erb
|
@@ -72,6 +89,7 @@ files:
|
|
72
89
|
- lib/atproto_auth/dpop/key_manager.rb
|
73
90
|
- lib/atproto_auth/dpop/nonce_manager.rb
|
74
91
|
- lib/atproto_auth/dpop/proof_generator.rb
|
92
|
+
- lib/atproto_auth/encryption.rb
|
75
93
|
- lib/atproto_auth/errors.rb
|
76
94
|
- lib/atproto_auth/http_client.rb
|
77
95
|
- lib/atproto_auth/identity.rb
|
@@ -83,6 +101,11 @@ files:
|
|
83
101
|
- lib/atproto_auth/par/request.rb
|
84
102
|
- lib/atproto_auth/par/response.rb
|
85
103
|
- lib/atproto_auth/pkce.rb
|
104
|
+
- lib/atproto_auth/serialization/base.rb
|
105
|
+
- lib/atproto_auth/serialization/dpop_key.rb
|
106
|
+
- lib/atproto_auth/serialization/session.rb
|
107
|
+
- lib/atproto_auth/serialization/stored_nonce.rb
|
108
|
+
- lib/atproto_auth/serialization/token_set.rb
|
86
109
|
- lib/atproto_auth/server_metadata.rb
|
87
110
|
- lib/atproto_auth/server_metadata/authorization_server.rb
|
88
111
|
- lib/atproto_auth/server_metadata/origin_url.rb
|
@@ -91,6 +114,11 @@ files:
|
|
91
114
|
- lib/atproto_auth/state/session.rb
|
92
115
|
- lib/atproto_auth/state/session_manager.rb
|
93
116
|
- lib/atproto_auth/state/token_set.rb
|
117
|
+
- lib/atproto_auth/storage/interface.rb
|
118
|
+
- lib/atproto_auth/storage/key_builder.rb
|
119
|
+
- lib/atproto_auth/storage/memory.rb
|
120
|
+
- lib/atproto_auth/storage/redis.rb
|
121
|
+
- lib/atproto_auth/token/refresh.rb
|
94
122
|
- lib/atproto_auth/version.rb
|
95
123
|
- sig/atproto_auth.rbs
|
96
124
|
- sig/atproto_auth/client_metadata.rbs
|