authproto 0.3.0.beta1 → 0.3.0.beta2
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/.yardopts +1 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +48 -7
- data/README.md +20 -0
- data/examples/confidential_client/.gitignore +2 -1
- data/examples/confidential_client/Gemfile.lock +5 -1
- data/examples/confidential_client/README.md +2 -2
- data/examples/confidential_client/app.rb +17 -17
- data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
- data/examples/confidential_client/scripts/generate_keys.rb +15 -9
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/localhost.rb +8 -25
- data/examples/minisky_console/production.rb +1 -1
- data/lib/authproto/client.rb +150 -201
- data/lib/authproto/dpop/client.rb +12 -60
- data/lib/authproto/dpop/key_manager.rb +17 -17
- data/lib/authproto/dpop/nonce_manager.rb +5 -19
- data/lib/authproto/dpop/proof_generator.rb +27 -23
- data/lib/authproto/errors.rb +1 -1
- data/lib/authproto/http_client.rb +17 -31
- data/lib/authproto/localhost_server.rb +15 -6
- data/lib/authproto/metadata/authorization_server.rb +358 -0
- data/lib/authproto/metadata/client_metadata.rb +574 -0
- data/lib/authproto/metadata/resource_server.rb +137 -0
- data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
- data/lib/authproto/minisky.rb +8 -10
- data/lib/authproto/par/client.rb +51 -55
- data/lib/authproto/par/client_assertion.rb +58 -15
- data/lib/authproto/par/request.rb +58 -140
- data/lib/authproto/par/response.rb +9 -10
- data/lib/authproto/par.rb +3 -5
- data/lib/authproto/pkce.rb +29 -106
- data/lib/authproto/serialization/session.rb +11 -11
- data/lib/authproto/serialization/token_set.rb +6 -6
- data/lib/authproto/state/session.rb +27 -31
- data/lib/authproto/state/session_manager.rb +2 -2
- data/lib/authproto/state/token_set.rb +4 -4
- data/lib/authproto/state.rb +3 -3
- data/lib/authproto/token/refresh.rb +35 -43
- data/lib/authproto/validated_uri.rb +57 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +1 -0
- data/misc/yard_attributes.rb +34 -0
- data/sig/authproto/client.rbs +9 -10
- data/sig/authproto/dpop/client.rbs +1 -8
- data/sig/authproto/dpop/nonce_manager.rbs +0 -1
- data/sig/authproto/dpop/proof_generator.rbs +5 -5
- data/sig/authproto/http_client.rbs +2 -2
- data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
- data/sig/authproto/metadata/client_metadata.rbs +118 -0
- data/sig/authproto/metadata/resource_server.rbs +27 -0
- data/sig/authproto/minisky_oauth.rbs +5 -3
- data/sig/authproto/par/client.rbs +4 -6
- data/sig/authproto/par/client_assertion.rbs +2 -1
- data/sig/authproto/par/request.rbs +5 -19
- data/sig/authproto/par/response.rbs +1 -5
- data/sig/authproto/pkce.rbs +6 -16
- data/sig/authproto/serialization/session.rbs +2 -2
- data/sig/authproto/state/session.rbs +4 -7
- data/sig/authproto/state/session_manager.rbs +2 -2
- data/sig/authproto/token/refresh.rbs +4 -4
- data/sig/authproto/validated_uri.rbs +36 -0
- data/sig/authproto.rbs +5 -3
- metadata +13 -11
- data/lib/authproto/client_metadata.rb +0 -478
- data/lib/authproto/server_metadata/authorization_server.rb +0 -267
- data/lib/authproto/server_metadata/origin_url.rb +0 -71
- data/lib/authproto/server_metadata/resource_server.rb +0 -98
- data/sig/authproto/client_metadata.rbs +0 -113
- data/sig/authproto/server_metadata/origin_url.rbs +0 -21
- data/sig/authproto/server_metadata/resource_server.rbs +0 -27
|
@@ -1,116 +1,75 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'uri'
|
|
4
4
|
|
|
5
5
|
require_relative '../par'
|
|
6
|
+
require_relative 'client_assertion'
|
|
6
7
|
|
|
7
8
|
module AuthProto
|
|
8
9
|
module PAR
|
|
9
10
|
|
|
10
11
|
#
|
|
11
|
-
# Represents a pushed authorization request
|
|
12
|
+
# Represents a pushed authorization request.
|
|
12
13
|
#
|
|
13
14
|
|
|
14
15
|
class Request
|
|
15
16
|
|
|
16
17
|
#
|
|
17
|
-
# Configuration
|
|
18
|
+
# Shared set of fields between {Request} (a read-only, validated request) and {Configuration} (a draft request
|
|
19
|
+
# with writable fields configured in the block passed to {Request.build}).
|
|
18
20
|
#
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
module Fields
|
|
21
23
|
|
|
22
24
|
# OAuth client identifier.
|
|
23
|
-
# @return [String
|
|
24
|
-
|
|
25
|
+
# @return [String]
|
|
26
|
+
attr_reader :client_id
|
|
25
27
|
|
|
26
28
|
# Registered OAuth callback URI.
|
|
27
|
-
# @return [String
|
|
28
|
-
|
|
29
|
+
# @return [String]
|
|
30
|
+
attr_reader :redirect_uri
|
|
29
31
|
|
|
30
32
|
# S256 PKCE challenge.
|
|
31
|
-
# @return [String
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# PKCE method; must be `S256`.
|
|
35
|
-
# @return [String, nil]
|
|
36
|
-
attr_accessor :code_challenge_method
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :code_challenge
|
|
37
35
|
|
|
38
36
|
# Random state token to validate the callback.
|
|
39
|
-
# @return [String
|
|
40
|
-
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :state
|
|
41
39
|
|
|
42
40
|
# Space-separated scopes including `atproto`.
|
|
43
|
-
# @return [String
|
|
44
|
-
|
|
41
|
+
# @return [String]
|
|
42
|
+
attr_reader :scope
|
|
45
43
|
|
|
46
44
|
# Account handle to prefill at the authorization server.
|
|
47
45
|
# @return [String, nil]
|
|
48
|
-
|
|
46
|
+
attr_reader :login_hint
|
|
49
47
|
|
|
50
48
|
# Optional authorization nonce parameter.
|
|
51
49
|
# @return [String, nil]
|
|
52
|
-
|
|
50
|
+
attr_reader :nonce
|
|
53
51
|
|
|
54
52
|
# Optional proof value retained on the request; {Client#submit} generates its own HTTP proof.
|
|
55
53
|
# @return [String, nil]
|
|
56
|
-
|
|
54
|
+
attr_reader :dpop_proof
|
|
57
55
|
|
|
58
|
-
# JWT
|
|
59
|
-
# @return [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# Signed client authentication JWT for confidential clients.
|
|
63
|
-
# @return [String, nil]
|
|
64
|
-
attr_accessor :client_assertion
|
|
56
|
+
# Client authentication signer; a fresh JWT is generated for each form encoding.
|
|
57
|
+
# @return [ClientAssertion, nil]
|
|
58
|
+
attr_reader :client_assertion
|
|
65
59
|
end
|
|
66
60
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# OAuth client identifier.
|
|
72
|
-
# @return [String]
|
|
73
|
-
attr_reader :client_id
|
|
74
|
-
|
|
75
|
-
# Registered OAuth callback URI.
|
|
76
|
-
# @return [String]
|
|
77
|
-
attr_reader :redirect_uri
|
|
78
|
-
|
|
79
|
-
# S256 PKCE challenge.
|
|
80
|
-
# @return [String]
|
|
81
|
-
attr_reader :code_challenge
|
|
82
|
-
|
|
83
|
-
# PKCE method; must be `S256`.
|
|
84
|
-
# @return [String]
|
|
85
|
-
attr_reader :code_challenge_method
|
|
86
|
-
|
|
87
|
-
# Random state token to validate the callback.
|
|
88
|
-
# @return [String]
|
|
89
|
-
attr_reader :state
|
|
90
|
-
|
|
91
|
-
# Space-separated scopes including `atproto`.
|
|
92
|
-
# @return [String]
|
|
93
|
-
attr_reader :scope
|
|
94
|
-
|
|
95
|
-
# Account handle to prefill at the authorization server.
|
|
96
|
-
# @return [String, nil]
|
|
97
|
-
attr_reader :login_hint
|
|
61
|
+
#
|
|
62
|
+
# A draft PAR request with writable fields, configured inside the block passed to {Request.build}).
|
|
63
|
+
#
|
|
98
64
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
attr_reader :nonce
|
|
65
|
+
class Configuration
|
|
66
|
+
include Fields
|
|
102
67
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
attr_reader :dpop_proof
|
|
68
|
+
send(:attr_writer, *Fields.instance_methods(false))
|
|
69
|
+
end
|
|
106
70
|
|
|
107
|
-
|
|
108
|
-
# @return [String, nil]
|
|
109
|
-
attr_reader :client_assertion_type
|
|
71
|
+
include Fields
|
|
110
72
|
|
|
111
|
-
# Signed client authentication JWT for confidential clients.
|
|
112
|
-
# @return [String, nil]
|
|
113
|
-
attr_reader :client_assertion
|
|
114
73
|
|
|
115
74
|
# Builds a validated PAR request by yielding a mutable parameter configuration.
|
|
116
75
|
#
|
|
@@ -132,111 +91,70 @@ module AuthProto
|
|
|
132
91
|
# @raise [Error] if required values are missing or authentication/PKCE/scope values are invalid
|
|
133
92
|
#
|
|
134
93
|
def initialize(config)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
@redirect_uri = config.redirect_uri
|
|
139
|
-
@code_challenge = config.code_challenge
|
|
140
|
-
@code_challenge_method = config.code_challenge_method
|
|
141
|
-
@state = config.state
|
|
142
|
-
@scope = config.scope
|
|
143
|
-
|
|
144
|
-
# Optional parameters
|
|
145
|
-
@login_hint = config.login_hint
|
|
146
|
-
@nonce = config.nonce
|
|
147
|
-
@dpop_proof = config.dpop_proof
|
|
148
|
-
|
|
149
|
-
# Client authentication
|
|
150
|
-
@client_assertion_type = config.client_assertion_type
|
|
151
|
-
@client_assertion = config.client_assertion
|
|
94
|
+
Fields.instance_methods(false).each do |field|
|
|
95
|
+
self.instance_variable_set("@#{field}", config.send(field))
|
|
96
|
+
end
|
|
152
97
|
|
|
153
98
|
validate_request
|
|
154
99
|
end
|
|
155
100
|
|
|
156
|
-
# Converts request to form-encoded parameters
|
|
101
|
+
# Converts request to form-encoded parameters, generating a fresh client assertion when configured.
|
|
102
|
+
# @param audience [String, nil] authorization server issuer; required for confidential clients
|
|
157
103
|
# @return [String] Form-encoded request body
|
|
158
|
-
|
|
159
|
-
|
|
104
|
+
#
|
|
105
|
+
def to_form(audience: nil)
|
|
106
|
+
URI.encode_www_form(build_params(audience: audience))
|
|
160
107
|
end
|
|
161
108
|
|
|
109
|
+
|
|
162
110
|
private
|
|
163
111
|
|
|
164
|
-
def build_params
|
|
112
|
+
def build_params(audience:)
|
|
165
113
|
params = {
|
|
166
|
-
"response_type" =>
|
|
114
|
+
"response_type" => 'code',
|
|
167
115
|
"client_id" => client_id,
|
|
168
116
|
"redirect_uri" => redirect_uri,
|
|
169
117
|
"code_challenge" => code_challenge,
|
|
170
|
-
"code_challenge_method" =>
|
|
118
|
+
"code_challenge_method" => PKCE::CHALLENGE_METHOD_S256,
|
|
171
119
|
"state" => state,
|
|
172
120
|
"scope" => scope
|
|
173
121
|
}
|
|
174
122
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
params
|
|
178
|
-
end
|
|
123
|
+
params['login_hint'] = login_hint if login_hint
|
|
124
|
+
params['nonce'] = nonce if nonce
|
|
179
125
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
126
|
+
if client_assertion
|
|
127
|
+
unless audience.is_a?(String) && !audience.empty?
|
|
128
|
+
raise Error, "audience is required with client_assertion"
|
|
129
|
+
end
|
|
184
130
|
|
|
185
|
-
|
|
186
|
-
|
|
131
|
+
params['client_assertion_type'] = CLIENT_ASSERTION_TYPE
|
|
132
|
+
params['client_assertion'] = client_assertion.generate_jwt(audience: audience)
|
|
133
|
+
end
|
|
187
134
|
|
|
188
|
-
params
|
|
189
|
-
params["client_assertion"] = client_assertion
|
|
135
|
+
params
|
|
190
136
|
end
|
|
191
137
|
|
|
192
138
|
def validate_request
|
|
193
139
|
validate_required_params
|
|
194
|
-
validate_response_type
|
|
195
|
-
validate_code_challenge_method
|
|
196
|
-
validate_scope
|
|
197
140
|
validate_client_auth
|
|
141
|
+
|
|
142
|
+
raise Error, "atproto scope is required" unless scope.split.include?('atproto')
|
|
198
143
|
end
|
|
199
144
|
|
|
200
145
|
def validate_required_params
|
|
201
|
-
%i[client_id redirect_uri code_challenge
|
|
146
|
+
%i[client_id redirect_uri code_challenge state scope].each do |param|
|
|
202
147
|
value = send(param)
|
|
203
148
|
raise Error, "#{param} is required" if value.nil? || value.empty?
|
|
204
149
|
end
|
|
205
150
|
end
|
|
206
151
|
|
|
207
|
-
def validate_response_type
|
|
208
|
-
return if response_type == "code"
|
|
209
|
-
|
|
210
|
-
raise Error, "response_type must be 'code'"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def validate_code_challenge_method
|
|
214
|
-
return if code_challenge_method == "S256"
|
|
215
|
-
|
|
216
|
-
raise Error, "code_challenge_method must be 'S256'"
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def validate_scope
|
|
220
|
-
scopes = scope.split
|
|
221
|
-
raise Error, "atproto scope is required" unless scopes.include?("atproto")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
152
|
def validate_client_auth
|
|
225
|
-
|
|
226
|
-
has_assertion = !client_assertion.nil?
|
|
227
|
-
has_type = !client_assertion_type.nil?
|
|
153
|
+
return if client_assertion.nil?
|
|
228
154
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
raise Error, "client_assertion_type must be #{CLIENT_ASSERTION_TYPE}"
|
|
155
|
+
unless client_assertion.is_a?(ClientAssertion)
|
|
156
|
+
raise Error, "client_assertion must be a PAR::ClientAssertion"
|
|
232
157
|
end
|
|
233
|
-
|
|
234
|
-
raise Error, "client_assertion required with client_assertion_type" if client_assertion.nil?
|
|
235
|
-
raise Error, "client_assertion_type required with client_assertion" if client_assertion_type.nil?
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def encode_params(params)
|
|
239
|
-
params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
240
158
|
end
|
|
241
159
|
end
|
|
242
160
|
end
|
|
@@ -22,21 +22,20 @@ module AuthProto
|
|
|
22
22
|
# Creates and validates a PAR response.
|
|
23
23
|
#
|
|
24
24
|
# @param request_uri [String] nonempty request URI issued by the authorization server
|
|
25
|
-
# @param expires_in [Integer
|
|
26
|
-
# @raise [Error] if the URI is
|
|
25
|
+
# @param expires_in [Integer] positive lifetime in seconds
|
|
26
|
+
# @raise [Error] if the URI is not a nonempty string or expiry is not a positive integer
|
|
27
27
|
#
|
|
28
28
|
def initialize(request_uri:, expires_in:)
|
|
29
29
|
@request_uri = request_uri
|
|
30
|
-
@expires_in = expires_in
|
|
31
|
-
validate_response
|
|
32
|
-
end
|
|
33
|
-
|
|
30
|
+
@expires_in = expires_in
|
|
34
31
|
|
|
35
|
-
|
|
32
|
+
unless @request_uri.is_a?(String) && !@request_uri.empty?
|
|
33
|
+
raise Error, "request_uri must be a nonempty string"
|
|
34
|
+
end
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
unless @expires_in.is_a?(Integer) && @expires_in.positive?
|
|
37
|
+
raise Error, "expires_in must be a positive integer"
|
|
38
|
+
end
|
|
40
39
|
end
|
|
41
40
|
end
|
|
42
41
|
end
|
data/lib/authproto/par.rb
CHANGED
|
@@ -15,7 +15,7 @@ module AuthProto
|
|
|
15
15
|
#
|
|
16
16
|
# @example Basic PAR request
|
|
17
17
|
# par = AuthProto::PAR::Client.new(
|
|
18
|
-
#
|
|
18
|
+
# authorization_server: authorization_server,
|
|
19
19
|
# dpop_client: AuthProto::DPoP::Client.new
|
|
20
20
|
# )
|
|
21
21
|
#
|
|
@@ -23,14 +23,12 @@ module AuthProto
|
|
|
23
23
|
# config.client_id = "https://app.example.com/oauth-client-metadata.json"
|
|
24
24
|
# config.redirect_uri = "https://app.example.com/callback"
|
|
25
25
|
# config.code_challenge = "abc123..."
|
|
26
|
-
# config.code_challenge_method = "S256"
|
|
27
26
|
# config.state = "xyz789..."
|
|
28
|
-
# config.scope =
|
|
27
|
+
# config.scope = 'atproto'
|
|
29
28
|
# end
|
|
30
29
|
#
|
|
31
30
|
# response = par.submit(request)
|
|
32
|
-
# auth_url = par.
|
|
33
|
-
# authorize_endpoint: "https://auth.example.com/authorize",
|
|
31
|
+
# auth_url = par.build_authorization_url(
|
|
34
32
|
# request_uri: response.request_uri,
|
|
35
33
|
# client_id: request.client_id
|
|
36
34
|
# )
|
data/lib/authproto/pkce.rb
CHANGED
|
@@ -9,117 +9,40 @@ require_relative 'errors'
|
|
|
9
9
|
module AuthProto
|
|
10
10
|
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
# as specified in RFC 7636.
|
|
14
|
-
#
|
|
15
|
-
# This module provides functionality to:
|
|
16
|
-
# - Generate cryptographically secure code verifiers
|
|
17
|
-
# - Create SHA-256 code challenges from verifiers
|
|
18
|
-
# - Verify challenge/verifier pairs
|
|
19
|
-
#
|
|
20
|
-
# Only the S256 challenge method is supported, as required by AT Protocol OAuth.
|
|
12
|
+
# Generates a matching PKCE verifier and S256 challenge for an OAuth authorization flow.
|
|
21
13
|
#
|
|
22
14
|
|
|
23
|
-
|
|
15
|
+
class PKCE
|
|
24
16
|
|
|
25
|
-
# Error raised
|
|
17
|
+
# Error raised when generating a verifier/challenge pair fails.
|
|
26
18
|
class Error < AuthProto::Error; end
|
|
27
19
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
rescue StandardError => e
|
|
55
|
-
raise Error, "Failed to generate verifier: #{e.message}"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Creates a code challenge from a verifier using SHA-256
|
|
59
|
-
#
|
|
60
|
-
# @param verifier [String] The code verifier to create challenge from
|
|
61
|
-
# @return [String] Base64URL-encoded SHA-256 hash of the verifier
|
|
62
|
-
# @raise [Error] if verifier is invalid or hashing fails
|
|
63
|
-
#
|
|
64
|
-
def generate_challenge(verifier)
|
|
65
|
-
validate_verifier(verifier)
|
|
66
|
-
|
|
67
|
-
# Hash with SHA-256 and encode as URL-safe base64
|
|
68
|
-
digest = OpenSSL::Digest::SHA256.digest(verifier)
|
|
69
|
-
Base64.urlsafe_encode64(digest, padding: false)
|
|
70
|
-
rescue StandardError => e
|
|
71
|
-
raise Error, "Failed to generate challenge: #{e.message}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Verifies that a challenge matches a verifier
|
|
75
|
-
#
|
|
76
|
-
# @param challenge [String] The code challenge to verify
|
|
77
|
-
# @param verifier [String] The code verifier to check against
|
|
78
|
-
# @return [Boolean] true if challenge matches verifier
|
|
79
|
-
# @raise [Error] if inputs are invalid
|
|
80
|
-
#
|
|
81
|
-
def verify(challenge, verifier)
|
|
82
|
-
# Generate challenge from verifier and compare
|
|
83
|
-
calculated = generate_challenge(verifier)
|
|
84
|
-
secure_compare(calculated, challenge)
|
|
85
|
-
rescue Error
|
|
86
|
-
# Re-raise PKCE errors
|
|
87
|
-
raise
|
|
88
|
-
rescue StandardError => e
|
|
89
|
-
raise Error, "Challenge verification failed: #{e.message}"
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
def validate_verifier_length(length)
|
|
96
|
-
return if length.is_a?(Integer) && length.between?(MIN_VERIFIER_LENGTH, MAX_VERIFIER_LENGTH)
|
|
97
|
-
|
|
98
|
-
raise Error, "Verifier length must be between #{MIN_VERIFIER_LENGTH} and #{MAX_VERIFIER_LENGTH}"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def validate_verifier(verifier)
|
|
102
|
-
raise Error, "Verifier cannot be nil" if verifier.nil?
|
|
103
|
-
raise Error, "Verifier cannot be empty" if verifier.empty?
|
|
104
|
-
|
|
105
|
-
length = verifier.length
|
|
106
|
-
validate_verifier_length(length)
|
|
107
|
-
|
|
108
|
-
return if verifier.match?(ALLOWED_VERIFIER_CHARS)
|
|
109
|
-
|
|
110
|
-
raise Error, "Verifier contains invalid characters"
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Constant-time string comparison to prevent timing attacks
|
|
114
|
-
def secure_compare(str1, str2)
|
|
115
|
-
return false unless str1.bytesize == str2.bytesize
|
|
116
|
-
|
|
117
|
-
left = str1.unpack("C*")
|
|
118
|
-
right = str2.unpack("C*")
|
|
119
|
-
result = 0
|
|
120
|
-
left.zip(right) { |x, y| result |= x ^ y }
|
|
121
|
-
result.zero?
|
|
122
|
-
end
|
|
20
|
+
# Challenge method required by AT Protocol OAuth.
|
|
21
|
+
CHALLENGE_METHOD_S256 = 'S256'
|
|
22
|
+
|
|
23
|
+
# Random bytes providing 256 bits of entropy, encoded as a 43-character verifier without padding.
|
|
24
|
+
VERIFIER_BYTES = 32
|
|
25
|
+
|
|
26
|
+
# Cryptographically random, URL-safe code verifier.
|
|
27
|
+
# @return [String]
|
|
28
|
+
attr_reader :verifier
|
|
29
|
+
|
|
30
|
+
# Base64URL-encoded SHA-256 digest of the verifier, without padding.
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_reader :challenge
|
|
33
|
+
|
|
34
|
+
# Generates a verifier and its matching S256 challenge.
|
|
35
|
+
#
|
|
36
|
+
# Both strings are frozen so the pair cannot be changed after generation.
|
|
37
|
+
#
|
|
38
|
+
# @raise [Error] if random generation or hashing fails
|
|
39
|
+
#
|
|
40
|
+
def initialize
|
|
41
|
+
@verifier = SecureRandom.urlsafe_base64(VERIFIER_BYTES, false).freeze
|
|
42
|
+
digest = OpenSSL::Digest::SHA256.digest(@verifier)
|
|
43
|
+
@challenge = Base64.urlsafe_encode64(digest, padding: false).freeze
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
raise Error, "Failed to generate PKCE: #{e.message}"
|
|
123
46
|
end
|
|
124
47
|
end
|
|
125
48
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '../
|
|
3
|
+
require_relative '../metadata/authorization_server'
|
|
4
4
|
require_relative '../state/session'
|
|
5
5
|
require_relative 'base'
|
|
6
6
|
require_relative 'token_set'
|
|
@@ -44,19 +44,19 @@ module AuthProto
|
|
|
44
44
|
|
|
45
45
|
def deserialize_data(data)
|
|
46
46
|
AuthProto::State::Session.new(
|
|
47
|
-
client_id: data[
|
|
48
|
-
scope: data[
|
|
49
|
-
did: data[
|
|
50
|
-
auth_server: deserialize_auth_server(data[
|
|
47
|
+
client_id: data['client_id'],
|
|
48
|
+
scope: data['scope'],
|
|
49
|
+
did: data['did'],
|
|
50
|
+
auth_server: deserialize_auth_server(data['auth_server'])
|
|
51
51
|
).tap do |session|
|
|
52
52
|
# Set readonly attributes
|
|
53
|
-
session.instance_variable_set(:@session_id, data[
|
|
54
|
-
session.instance_variable_set(:@state_token, data[
|
|
55
|
-
session.instance_variable_set(:@pkce_verifier, data[
|
|
56
|
-
session.instance_variable_set(:@pkce_challenge, data[
|
|
53
|
+
session.instance_variable_set(:@session_id, data['session_id'])
|
|
54
|
+
session.instance_variable_set(:@state_token, data['state_token'])
|
|
55
|
+
session.instance_variable_set(:@pkce_verifier, data['pkce_verifier'])
|
|
56
|
+
session.instance_variable_set(:@pkce_challenge, data['pkce_challenge'])
|
|
57
57
|
|
|
58
58
|
# Set tokens if present
|
|
59
|
-
session.tokens = deserialize_token_set(data[
|
|
59
|
+
session.tokens = deserialize_token_set(data['tokens']) if data['tokens']
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -81,7 +81,7 @@ module AuthProto
|
|
|
81
81
|
def deserialize_auth_server(data)
|
|
82
82
|
return nil unless data
|
|
83
83
|
|
|
84
|
-
AuthProto::
|
|
84
|
+
AuthProto::Metadata::AuthorizationServer.new(data)
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
end
|
|
@@ -41,12 +41,12 @@ module AuthProto
|
|
|
41
41
|
|
|
42
42
|
def deserialize_data(data)
|
|
43
43
|
AuthProto::State::TokenSet.new(
|
|
44
|
-
access_token: data[
|
|
45
|
-
refresh_token: data[
|
|
46
|
-
token_type: data[
|
|
47
|
-
expires_in: (Time.parse(data[
|
|
48
|
-
scope: data[
|
|
49
|
-
sub: data[
|
|
44
|
+
access_token: data['access_token'],
|
|
45
|
+
refresh_token: data['refresh_token'],
|
|
46
|
+
token_type: data['token_type'],
|
|
47
|
+
expires_in: (Time.parse(data['expires_at']) - Time.now).to_i,
|
|
48
|
+
scope: data['scope'],
|
|
49
|
+
sub: data['sub']
|
|
50
50
|
)
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -41,7 +41,7 @@ module AuthProto
|
|
|
41
41
|
attr_reader :pkce_challenge
|
|
42
42
|
|
|
43
43
|
# Bound authorization server, once discovered.
|
|
44
|
-
# @return [
|
|
44
|
+
# @return [Metadata::AuthorizationServer, nil]
|
|
45
45
|
attr_reader :auth_server
|
|
46
46
|
|
|
47
47
|
# Bound account DID, once resolved.
|
|
@@ -56,7 +56,7 @@ module AuthProto
|
|
|
56
56
|
#
|
|
57
57
|
# @param client_id [String] OAuth client ID
|
|
58
58
|
# @param scope [String] requested space-separated scopes
|
|
59
|
-
# @param auth_server [
|
|
59
|
+
# @param auth_server [Metadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
60
60
|
# @param did [String, nil] pre-resolved account DID
|
|
61
61
|
# @raise [PKCE::Error] if PKCE generation fails
|
|
62
62
|
#
|
|
@@ -70,17 +70,17 @@ module AuthProto
|
|
|
70
70
|
@auth_server = auth_server
|
|
71
71
|
@did = did
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
@pkce_verifier =
|
|
75
|
-
@pkce_challenge =
|
|
73
|
+
pkce = PKCE.new
|
|
74
|
+
@pkce_verifier = pkce.verifier
|
|
75
|
+
@pkce_challenge = pkce.challenge
|
|
76
76
|
|
|
77
77
|
@tokens = nil
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# Binds this session to an authorization server.
|
|
81
81
|
#
|
|
82
|
-
# @param server [
|
|
83
|
-
# @return [
|
|
82
|
+
# @param server [Metadata::AuthorizationServer] resolved authorization server
|
|
83
|
+
# @return [Metadata::AuthorizationServer] assigned server when called as a method
|
|
84
84
|
# @raise [SessionError] if already bound to a server with a different issuer
|
|
85
85
|
#
|
|
86
86
|
def authorization_server=(server)
|
|
@@ -124,6 +124,26 @@ module AuthProto
|
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Returns the current tokens and session identifiers, with expiry in seconds from now.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash{Symbol => String, Integer, nil}, nil] session data, or `nil` before tokens are assigned
|
|
130
|
+
#
|
|
131
|
+
def session_data
|
|
132
|
+
synchronize do
|
|
133
|
+
return nil unless @tokens
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
access_token: @tokens.access_token,
|
|
137
|
+
token_type: @tokens.token_type,
|
|
138
|
+
expires_in: (@tokens.expires_at - Time.now).to_i,
|
|
139
|
+
refresh_token: @tokens.refresh_token,
|
|
140
|
+
scope: @tokens.scope,
|
|
141
|
+
session_id: @session_id,
|
|
142
|
+
did: @did
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
127
147
|
# Whether this session has valid access tokens
|
|
128
148
|
# @return [Boolean]
|
|
129
149
|
def authorized?
|
|
@@ -139,30 +159,6 @@ module AuthProto
|
|
|
139
159
|
!@tokens.nil? && @tokens.renewable?
|
|
140
160
|
end
|
|
141
161
|
end
|
|
142
|
-
|
|
143
|
-
# Compares a supplied state token with the session token in constant time.
|
|
144
|
-
#
|
|
145
|
-
# @param state [String, nil] callback state token
|
|
146
|
-
# @return [Boolean] whether it matches; `false` for `nil` or a different length
|
|
147
|
-
#
|
|
148
|
-
def validate_state(state)
|
|
149
|
-
return false unless state
|
|
150
|
-
|
|
151
|
-
# Use secure comparison to prevent timing attacks
|
|
152
|
-
secure_compare(@state_token, state)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
private
|
|
156
|
-
|
|
157
|
-
def secure_compare(str1, str2)
|
|
158
|
-
return false unless str1.bytesize == str2.bytesize
|
|
159
|
-
|
|
160
|
-
left = str1.unpack("C*")
|
|
161
|
-
right = str2.unpack("C*")
|
|
162
|
-
result = 0
|
|
163
|
-
left.zip(right) { |x, y| result |= x ^ y }
|
|
164
|
-
result.zero?
|
|
165
|
-
end
|
|
166
162
|
end
|
|
167
163
|
end
|
|
168
164
|
end
|
|
@@ -25,7 +25,7 @@ module AuthProto
|
|
|
25
25
|
#
|
|
26
26
|
# @param client_id [String] OAuth client ID
|
|
27
27
|
# @param scope [String] requested space-separated scopes
|
|
28
|
-
# @param auth_server [
|
|
28
|
+
# @param auth_server [Metadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
29
29
|
# @param did [String, nil] pre-resolved account DID
|
|
30
30
|
#
|
|
31
31
|
# @return [Session] newly created session
|
|
@@ -136,7 +136,7 @@ module AuthProto
|
|
|
136
136
|
# @return [void]
|
|
137
137
|
# @raise [Storage::StorageError] if deletion or locking fails
|
|
138
138
|
#
|
|
139
|
-
def
|
|
139
|
+
def delete_session(session_id)
|
|
140
140
|
session = get_session(session_id)
|
|
141
141
|
return unless session
|
|
142
142
|
|