atproto_auth 0.0.1 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +17 -2
- data/CHANGELOG.md +23 -2
- data/README.md +91 -5
- data/examples/confidential_client/.gitignore +2 -0
- data/examples/confidential_client/Gemfile +1 -0
- data/examples/confidential_client/Gemfile.lock +10 -1
- data/examples/confidential_client/README.md +86 -9
- data/examples/confidential_client/app.rb +83 -12
- data/examples/confidential_client/{public/client-metadata.json → config/client-metadata.example.json} +5 -4
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +0 -0
- data/examples/confidential_client/views/authorized.erb +1 -1
- data/lib/atproto_auth/client.rb +98 -38
- data/lib/atproto_auth/client_metadata.rb +2 -2
- data/lib/atproto_auth/configuration.rb +35 -1
- data/lib/atproto_auth/dpop/key_manager.rb +1 -1
- data/lib/atproto_auth/dpop/nonce_manager.rb +30 -47
- data/lib/atproto_auth/encryption.rb +156 -0
- data/lib/atproto_auth/http_client.rb +2 -2
- data/lib/atproto_auth/identity/document.rb +1 -1
- data/lib/atproto_auth/identity/resolver.rb +1 -1
- data/lib/atproto_auth/serialization/base.rb +189 -0
- data/lib/atproto_auth/serialization/dpop_key.rb +29 -0
- data/lib/atproto_auth/serialization/session.rb +77 -0
- data/lib/atproto_auth/serialization/stored_nonce.rb +37 -0
- data/lib/atproto_auth/serialization/token_set.rb +43 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +20 -1
- data/lib/atproto_auth/state/session_manager.rb +67 -20
- data/lib/atproto_auth/storage/interface.rb +112 -0
- data/lib/atproto_auth/storage/key_builder.rb +39 -0
- data/lib/atproto_auth/storage/memory.rb +191 -0
- data/lib/atproto_auth/storage/redis.rb +119 -0
- data/lib/atproto_auth/token/refresh.rb +249 -0
- data/lib/atproto_auth/version.rb +1 -1
- data/lib/atproto_auth.rb +29 -1
- metadata +32 -5
- data/examples/confidential_client/config/client-metadata.json +0 -25
@@ -0,0 +1,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
|