authproto 0.3.0.beta2 → 0.3.0.beta3
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/CHANGELOG.md +19 -0
- data/README.md +108 -5
- data/examples/confidential_client/README.md +10 -0
- data/examples/confidential_client/app.rb +8 -7
- data/examples/confidential_client/scripts/generate_keys.rb +1 -1
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/README.md +20 -1
- data/examples/minisky_console/localhost.rb +5 -3
- data/examples/minisky_console/production.rb +9 -3
- data/lib/authproto/client.rb +73 -90
- data/lib/authproto/configuration.rb +37 -16
- data/lib/authproto/dpop/client.rb +105 -40
- data/lib/authproto/dpop/{key_manager.rb → key_set.rb} +54 -120
- data/lib/authproto/dpop/nonce_manager.rb +33 -20
- data/lib/authproto/dpop/proof_generator.rb +17 -32
- data/lib/authproto/dpop.rb +19 -0
- data/lib/authproto/http_helpers.rb +85 -0
- data/lib/authproto/metadata/authorization_server.rb +12 -6
- data/lib/authproto/metadata/client_metadata.rb +18 -5
- data/lib/authproto/metadata/resource_server.rb +4 -2
- data/lib/authproto/minisky.rb +36 -34
- data/lib/authproto/par/client.rb +22 -73
- data/lib/authproto/par/client_assertion.rb +47 -16
- data/lib/authproto/par/request.rb +4 -8
- data/lib/authproto/par.rb +2 -1
- data/lib/authproto/serialization/session.rb +31 -13
- data/lib/authproto/serialization/token_set.rb +1 -2
- data/lib/authproto/state/session.rb +66 -6
- data/lib/authproto/state/session_manager.rb +95 -21
- data/lib/authproto/state/token_set.rb +16 -16
- data/lib/authproto/state.rb +1 -1
- data/lib/authproto/storage/key_builder.rb +3 -11
- data/lib/authproto/storage/memory.rb +8 -2
- data/lib/authproto/token/refresh.rb +26 -59
- data/lib/authproto/validated_uri.rb +23 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +0 -1
- data/sig/authproto/client.rbs +5 -7
- data/sig/authproto/dependencies.rbs +2 -0
- data/sig/authproto/dpop/client.rbs +11 -4
- data/sig/authproto/dpop/{key_manager.rbs → key_set.rbs} +9 -11
- data/sig/authproto/dpop/nonce_manager.rbs +4 -4
- data/sig/authproto/dpop/proof_generator.rbs +5 -10
- data/sig/authproto/dpop.rbs +6 -0
- data/sig/authproto/http_helpers.rbs +7 -0
- data/sig/authproto/localhost_server.rbs +1 -1
- data/sig/authproto/metadata/authorization_server.rbs +1 -0
- data/sig/authproto/metadata/client_metadata.rbs +2 -1
- data/sig/authproto/metadata/resource_server.rbs +1 -0
- data/sig/authproto/minisky_oauth.rbs +5 -2
- data/sig/authproto/par/client.rbs +4 -8
- data/sig/authproto/par/client_assertion.rbs +4 -0
- data/sig/authproto/par/request.rbs +0 -9
- data/sig/authproto/state/session.rbs +10 -3
- data/sig/authproto/state/session_manager.rbs +8 -3
- data/sig/authproto/state/token_set.rbs +1 -2
- data/sig/authproto/storage/key_builder.rbs +1 -2
- data/sig/authproto/token/refresh.rbs +3 -4
- data/sig/authproto.rbs +5 -2
- metadata +14 -19
- data/.rspec +0 -1
- data/.rubocop.yml +0 -31
- data/.tm_properties +0 -1
- data/.yardopts +0 -5
- data/AGENTS.md +0 -80
- data/Rakefile +0 -8
- data/examples/confidential_client/.gitignore +0 -6
- data/examples/minisky_console/.gitignore +0 -2
- data/lib/authproto/serialization/dpop_key.rb +0 -38
- data/misc/yard_attributes.rb +0 -34
- data/sig/authproto/serialization/dpop_key.rbs +0 -14
data/lib/authproto/par/client.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'uri'
|
|
5
5
|
|
|
6
6
|
require_relative '../configuration'
|
|
7
|
+
require_relative '../http_helpers'
|
|
7
8
|
require_relative '../par'
|
|
8
9
|
require_relative 'response'
|
|
9
10
|
|
|
@@ -37,7 +38,8 @@ module AuthProto
|
|
|
37
38
|
# config.scope = 'atproto'
|
|
38
39
|
# end
|
|
39
40
|
#
|
|
40
|
-
#
|
|
41
|
+
# session = AuthProto::State::Session.new(client_id: client_id, scope: 'atproto')
|
|
42
|
+
# response = client.submit(request, session: session)
|
|
41
43
|
#
|
|
42
44
|
# # Build authorization URL using response
|
|
43
45
|
# auth_url = client.build_authorization_url(
|
|
@@ -57,6 +59,7 @@ module AuthProto
|
|
|
57
59
|
#
|
|
58
60
|
|
|
59
61
|
class Client
|
|
62
|
+
include HTTPHelpers
|
|
60
63
|
|
|
61
64
|
# Authorization server supplying endpoints and the client assertion audience.
|
|
62
65
|
# @return [Metadata::AuthorizationServer]
|
|
@@ -79,54 +82,31 @@ module AuthProto
|
|
|
79
82
|
@authorization_server = authorization_server
|
|
80
83
|
@par_endpoint = authorization_server.pushed_authorization_request_endpoint
|
|
81
84
|
@dpop_client = dpop_client
|
|
82
|
-
@nonce_manager = dpop_client.nonce_manager
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
# Submits a PAR request, handling DPoP nonce requirements
|
|
86
88
|
#
|
|
87
89
|
# @param request [Request] The request to submit
|
|
90
|
+
# @param session [State::Session] session whose key signs PAR and any retries
|
|
88
91
|
# @return [Response] The PAR response
|
|
89
92
|
# @raise [Error] if request fails
|
|
90
93
|
#
|
|
91
|
-
def submit(request)
|
|
94
|
+
def submit(request, session:)
|
|
92
95
|
raise Error, "Expected a PAR::Request" unless request.is_a?(PAR::Request)
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return process_response(response) if response[:status] == 201
|
|
98
|
-
|
|
99
|
-
# Handle DPoP nonce requirement
|
|
100
|
-
if requires_nonce?(response)
|
|
101
|
-
nonce = extract_nonce(response)
|
|
102
|
-
store_nonce(nonce)
|
|
103
|
-
|
|
104
|
-
# Get stored nonce to verify
|
|
105
|
-
@nonce_manager.get(server_origin)
|
|
106
|
-
|
|
107
|
-
# Generate new proof with nonce and retry
|
|
108
|
-
response = make_request(request)
|
|
109
|
-
return process_response(response) if response[:status] == 201
|
|
97
|
+
response = dpop_client.with_nonce_handling(session, @par_endpoint) do
|
|
98
|
+
make_request(request, session: session)
|
|
110
99
|
end
|
|
111
100
|
|
|
112
|
-
|
|
101
|
+
if response[:status] == 201
|
|
102
|
+
handle_success_response(response)
|
|
103
|
+
else
|
|
104
|
+
handle_error_response(response)
|
|
105
|
+
end
|
|
113
106
|
rescue StandardError => e
|
|
114
107
|
raise Error, "PAR request failed: #{e.message}"
|
|
115
108
|
end
|
|
116
109
|
|
|
117
|
-
# Reads a nonce from a transport response hash.
|
|
118
|
-
#
|
|
119
|
-
# @param response [Hash{Symbol => Object}] response containing a `:headers` hash with a `DPoP-Nonce` header
|
|
120
|
-
# @return [String] server-provided nonce
|
|
121
|
-
# @raise [Error] if no supported nonce header is present
|
|
122
|
-
#
|
|
123
|
-
def extract_nonce(response)
|
|
124
|
-
nonce = response[:headers].find { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
125
|
-
raise Error, "No DPoP nonce provided in response" unless nonce
|
|
126
|
-
|
|
127
|
-
nonce
|
|
128
|
-
end
|
|
129
|
-
|
|
130
110
|
# Builds the browser URL using the client ID and returned PAR request URI.
|
|
131
111
|
#
|
|
132
112
|
# Retains existing query parameters.
|
|
@@ -155,48 +135,19 @@ module AuthProto
|
|
|
155
135
|
|
|
156
136
|
private
|
|
157
137
|
|
|
158
|
-
def make_request(request)
|
|
159
|
-
|
|
160
|
-
proof = dpop_client.generate_proof(
|
|
161
|
-
method: 'POST',
|
|
162
|
-
uri: @par_endpoint,
|
|
163
|
-
nonce: @nonce_manager.get(server_origin)
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
# Build headers including DPoP proof
|
|
167
|
-
headers = build_headers(request, proof)
|
|
138
|
+
def make_request(request, session:)
|
|
139
|
+
dpop_proof = dpop_client.generate_proof(session: session, method: 'POST', uri: @par_endpoint)
|
|
168
140
|
|
|
169
|
-
# Make the request
|
|
170
141
|
AuthProto.configuration.http_client.post(
|
|
171
142
|
@par_endpoint,
|
|
172
143
|
body: request.to_form(audience: @authorization_server.issuer),
|
|
173
|
-
headers:
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
146
|
+
"DPoP" => dpop_proof
|
|
147
|
+
}
|
|
174
148
|
)
|
|
175
149
|
end
|
|
176
150
|
|
|
177
|
-
def build_headers(request, dpop_proof)
|
|
178
|
-
{
|
|
179
|
-
"Content-Type" => "application/x-www-form-urlencoded",
|
|
180
|
-
"DPoP" => dpop_proof
|
|
181
|
-
}
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def requires_nonce?(response)
|
|
185
|
-
body = JSON.parse(response[:body])
|
|
186
|
-
body['error'] == DPoP::ERROR_USE_NONCE
|
|
187
|
-
rescue JSON::ParserError
|
|
188
|
-
false
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def store_nonce(nonce)
|
|
192
|
-
@nonce_manager.update(nonce: nonce, server_url: server_origin)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def server_origin
|
|
196
|
-
uri = URI(@par_endpoint)
|
|
197
|
-
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
151
|
def handle_error_response(response)
|
|
201
152
|
begin
|
|
202
153
|
error_data = JSON.parse(response[:body])
|
|
@@ -208,12 +159,10 @@ module AuthProto
|
|
|
208
159
|
raise Error, "PAR request failed: #{error_message} (status: #{response[:status]})"
|
|
209
160
|
end
|
|
210
161
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
content_type = (response[:headers] || {}).find { |key, _| key.downcase == 'content-type' }&.last
|
|
162
|
+
def handle_success_response(response)
|
|
163
|
+
content_type = response_header(response, 'Content-Type', strip_parameters: true)
|
|
215
164
|
|
|
216
|
-
unless content_type.to_s.
|
|
165
|
+
unless content_type.to_s.casecmp?('application/json')
|
|
217
166
|
raise Error, "PAR response must use application/json"
|
|
218
167
|
end
|
|
219
168
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jwt'
|
|
4
3
|
require 'jose'
|
|
4
|
+
require 'jwt'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
|
|
7
|
+
require_relative '../configuration'
|
|
7
8
|
require_relative '../errors'
|
|
8
9
|
|
|
9
10
|
module AuthProto
|
|
@@ -20,6 +21,12 @@ module AuthProto
|
|
|
20
21
|
# Raised when client assertion key import or JWT signing fails.
|
|
21
22
|
class Error < AuthProto::Error; end
|
|
22
23
|
|
|
24
|
+
# JWS signing algorithm used for client assertions.
|
|
25
|
+
# @return [String]
|
|
26
|
+
ALGORITHM = "ES256"
|
|
27
|
+
|
|
28
|
+
# Validated private EC P-256 key used by this reusable assertion signer.
|
|
29
|
+
# @return [JOSE::JWK]
|
|
23
30
|
attr_reader :signing_key
|
|
24
31
|
|
|
25
32
|
# Creates an ES256 client assertion signer.
|
|
@@ -27,9 +34,15 @@ module AuthProto
|
|
|
27
34
|
# @param client_id [String] client ID used in `iss` and `sub`
|
|
28
35
|
# @param signing_key [Hash, JOSE::JWK] private EC P-256 signing key, including its `kid`
|
|
29
36
|
# @raise [Error] if the key cannot sign with ES256 or its usage is incompatible
|
|
37
|
+
# (`key_ops`, when present, must contain `sign` and optionally `verify`, without duplicates)
|
|
38
|
+
# @raise [Error] if the client ID is not a nonblank string
|
|
30
39
|
#
|
|
31
40
|
def initialize(client_id:, signing_key:)
|
|
32
|
-
|
|
41
|
+
unless client_id.is_a?(String) && !client_id.strip.empty?
|
|
42
|
+
raise Error, "client_id must be a nonblank string"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@client_id = client_id.dup.freeze
|
|
33
46
|
@signing_key = validate_signing_key(signing_key)
|
|
34
47
|
rescue ClientAssertion::Error
|
|
35
48
|
raise
|
|
@@ -37,16 +50,24 @@ module AuthProto
|
|
|
37
50
|
raise Error, "Invalid signing_key: #{e.message}"
|
|
38
51
|
end
|
|
39
52
|
|
|
40
|
-
# Signs a short-lived client authentication JWT with ES256.
|
|
53
|
+
# Signs a fresh, short-lived client authentication JWT with the cached ES256 key.
|
|
41
54
|
#
|
|
42
55
|
# Includes `iss`, `sub`, `aud`, a unique `jti`, and `iat`/`exp` times.
|
|
43
56
|
#
|
|
44
57
|
# @param audience [String] authorization server issuer URL used as the assertion audience
|
|
45
|
-
# @param lifetime [Integer] validity in seconds; defaults to
|
|
58
|
+
# @param lifetime [Integer] positive validity in seconds; defaults to {Configuration#client_assertion_lifetime}
|
|
46
59
|
# @return [String] compact signed client assertion JWT
|
|
47
|
-
# @raise [Error] if
|
|
60
|
+
# @raise [Error] if the audience is not a nonblank string, lifetime is not a positive integer, or signing fails
|
|
48
61
|
#
|
|
49
|
-
def generate_jwt(audience:, lifetime:
|
|
62
|
+
def generate_jwt(audience:, lifetime: AuthProto.configuration.client_assertion_lifetime)
|
|
63
|
+
unless audience.is_a?(String) && !audience.strip.empty?
|
|
64
|
+
raise Error, "audience must be a nonblank string"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless lifetime.is_a?(Integer) && lifetime.positive?
|
|
68
|
+
raise Error, "lifetime must be a positive integer"
|
|
69
|
+
end
|
|
70
|
+
|
|
50
71
|
now = Time.now.to_i
|
|
51
72
|
|
|
52
73
|
payload = {
|
|
@@ -59,13 +80,12 @@ module AuthProto
|
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
header = {
|
|
62
|
-
alg:
|
|
83
|
+
alg: ALGORITHM,
|
|
63
84
|
typ: 'JWT',
|
|
64
85
|
kid: @signing_key.fields['kid']
|
|
65
86
|
}
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
JWT.encode(payload, key, 'ES256', header)
|
|
88
|
+
JWT.encode(payload, @jwt_signing_key, ALGORITHM, header)
|
|
69
89
|
rescue JWT::Error, ArgumentError => e
|
|
70
90
|
raise Error, "Failed to generate client assertion: #{e.message}"
|
|
71
91
|
end
|
|
@@ -93,17 +113,28 @@ module AuthProto
|
|
|
93
113
|
end
|
|
94
114
|
end
|
|
95
115
|
|
|
96
|
-
if (data.key?('alg') && data['alg'] !=
|
|
97
|
-
(data.key?('use') && data['use'] != 'sig')
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
if (data.key?('alg') && data['alg'] != ALGORITHM) ||
|
|
117
|
+
(data.key?('use') && data['use'] != 'sig')
|
|
118
|
+
raise Error, "signing_key must be active and permit #{ALGORITHM} signing"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if data.key?('key_ops')
|
|
122
|
+
raise Error, "signing_key.key_ops is not an array" unless data['key_ops'].is_a?(Array)
|
|
123
|
+
|
|
124
|
+
ops = data['key_ops'].sort_by(&:to_s)
|
|
125
|
+
|
|
126
|
+
unless ops == ['sign'] || ops == ['sign', 'verify']
|
|
127
|
+
raise Error, "signing_key.key_ops must contain sign and optionally verify, without duplicates"
|
|
128
|
+
end
|
|
100
129
|
end
|
|
101
130
|
|
|
102
131
|
signing_key = JOSE::JWK.from_map(data)
|
|
103
132
|
|
|
104
|
-
|
|
105
|
-
proof = JWT.encode({},
|
|
106
|
-
JWT.decode(proof,
|
|
133
|
+
imported_key = JWT::JWK.import(data)
|
|
134
|
+
proof = JWT.encode({}, imported_key, ALGORITHM)
|
|
135
|
+
JWT.decode(proof, imported_key.public_key, true, algorithms: [ALGORITHM])
|
|
136
|
+
|
|
137
|
+
@jwt_signing_key = imported_key
|
|
107
138
|
|
|
108
139
|
signing_key
|
|
109
140
|
end
|
|
@@ -41,7 +41,7 @@ module AuthProto
|
|
|
41
41
|
# @return [String]
|
|
42
42
|
attr_reader :scope
|
|
43
43
|
|
|
44
|
-
# Account handle to prefill at the authorization server.
|
|
44
|
+
# Account handle or DID to prefill at the authorization server.
|
|
45
45
|
# @return [String, nil]
|
|
46
46
|
attr_reader :login_hint
|
|
47
47
|
|
|
@@ -49,17 +49,13 @@ module AuthProto
|
|
|
49
49
|
# @return [String, nil]
|
|
50
50
|
attr_reader :nonce
|
|
51
51
|
|
|
52
|
-
# Optional proof value retained on the request; {Client#submit} generates its own HTTP proof.
|
|
53
|
-
# @return [String, nil]
|
|
54
|
-
attr_reader :dpop_proof
|
|
55
|
-
|
|
56
52
|
# Client authentication signer; a fresh JWT is generated for each form encoding.
|
|
57
53
|
# @return [ClientAssertion, nil]
|
|
58
54
|
attr_reader :client_assertion
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
#
|
|
62
|
-
# A draft PAR request with writable fields, configured inside the block passed to {Request.build}
|
|
58
|
+
# A draft PAR request with writable fields, configured inside the block passed to {Request.build}.
|
|
63
59
|
#
|
|
64
60
|
|
|
65
61
|
class Configuration
|
|
@@ -77,7 +73,7 @@ module AuthProto
|
|
|
77
73
|
# @yieldparam config [Configuration] mutable request settings
|
|
78
74
|
# @yieldreturn [Object] ignored
|
|
79
75
|
# @return [Request] validated pushed authorization request
|
|
80
|
-
# @raise [Error] if required parameters
|
|
76
|
+
# @raise [Error] if required parameters are missing, `atproto` scope is absent or the signer has an invalid type
|
|
81
77
|
#
|
|
82
78
|
def self.build
|
|
83
79
|
config = Configuration.new
|
|
@@ -88,7 +84,7 @@ module AuthProto
|
|
|
88
84
|
# Copies and validates a PAR parameter configuration.
|
|
89
85
|
#
|
|
90
86
|
# @param config [Configuration] populated request settings
|
|
91
|
-
# @raise [Error] if required values are missing
|
|
87
|
+
# @raise [Error] if required values are missing, `atproto` scope is absent or the signer has an invalid type
|
|
92
88
|
#
|
|
93
89
|
def initialize(config)
|
|
94
90
|
Fields.instance_methods(false).each do |field|
|
data/lib/authproto/par.rb
CHANGED
|
@@ -27,7 +27,8 @@ module AuthProto
|
|
|
27
27
|
# config.scope = 'atproto'
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
30
|
-
#
|
|
30
|
+
# session = AuthProto::State::Session.new(client_id: client_id, scope: 'atproto')
|
|
31
|
+
# response = par.submit(request, session: session)
|
|
31
32
|
# auth_url = par.build_authorization_url(
|
|
32
33
|
# request_uri: response.request_uri,
|
|
33
34
|
# client_id: request.client_id
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
require_relative '../metadata/authorization_server'
|
|
4
6
|
require_relative '../state/session'
|
|
5
7
|
require_relative 'base'
|
|
@@ -29,10 +31,10 @@ module AuthProto
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def serialize_data(obj)
|
|
32
|
-
{
|
|
34
|
+
data = {
|
|
33
35
|
session_id: obj.session_id,
|
|
34
|
-
state_token: obj.state_token,
|
|
35
36
|
client_id: obj.client_id,
|
|
37
|
+
dpop_key: obj.dpop_key.private_jwk,
|
|
36
38
|
scope: obj.scope,
|
|
37
39
|
pkce_verifier: obj.pkce_verifier,
|
|
38
40
|
pkce_challenge: obj.pkce_challenge,
|
|
@@ -40,24 +42,40 @@ module AuthProto
|
|
|
40
42
|
tokens: serialize_token_set(obj.tokens),
|
|
41
43
|
auth_server: serialize_auth_server(obj.auth_server)
|
|
42
44
|
}
|
|
45
|
+
|
|
46
|
+
data[:state_token] = obj.state_token if obj.state_token
|
|
47
|
+
data[:signing_key_id] = obj.signing_key_id if obj.signing_key_id
|
|
48
|
+
data[:signing_key_thumbprint] = obj.signing_key_thumbprint if obj.signing_key_thumbprint
|
|
49
|
+
data[:authorization_expires_at] = obj.authorization_expires_at.utc.iso8601(9) if obj.authorization_expires_at
|
|
50
|
+
data
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
def deserialize_data(data)
|
|
46
|
-
|
|
54
|
+
unless data['dpop_key'].is_a?(Hash)
|
|
55
|
+
raise ValidationError, "Session is missing its private DPoP key; authorize again"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
session = AuthProto::State::Session.new(
|
|
47
59
|
client_id: data['client_id'],
|
|
60
|
+
dpop_key: AuthProto::DPoP::KeySet.from_jwk(data.fetch('dpop_key')),
|
|
48
61
|
scope: data['scope'],
|
|
49
62
|
did: data['did'],
|
|
63
|
+
signing_key_id: data['signing_key_id'],
|
|
64
|
+
signing_key_thumbprint: data['signing_key_thumbprint'],
|
|
65
|
+
authorization_expires_at: data['authorization_expires_at'] && Time.iso8601(data['authorization_expires_at']),
|
|
50
66
|
auth_server: deserialize_auth_server(data['auth_server'])
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Set readonly attributes
|
|
70
|
+
session.instance_variable_set(:@session_id, data['session_id'])
|
|
71
|
+
session.instance_variable_set(:@state_token, data['state_token'])
|
|
72
|
+
session.instance_variable_set(:@pkce_verifier, data['pkce_verifier'])
|
|
73
|
+
session.instance_variable_set(:@pkce_challenge, data['pkce_challenge'])
|
|
74
|
+
|
|
75
|
+
# Set tokens if present
|
|
76
|
+
session.tokens = deserialize_token_set(data['tokens']) if data['tokens']
|
|
77
|
+
|
|
78
|
+
session
|
|
61
79
|
end
|
|
62
80
|
|
|
63
81
|
def serialize_token_set(tokens)
|
|
@@ -32,7 +32,6 @@ module AuthProto
|
|
|
32
32
|
{
|
|
33
33
|
access_token: obj.access_token,
|
|
34
34
|
refresh_token: obj.refresh_token,
|
|
35
|
-
token_type: obj.token_type,
|
|
36
35
|
expires_at: obj.expires_at.utc.iso8601,
|
|
37
36
|
scope: obj.scope,
|
|
38
37
|
sub: obj.sub
|
|
@@ -43,7 +42,7 @@ module AuthProto
|
|
|
43
42
|
AuthProto::State::TokenSet.new(
|
|
44
43
|
access_token: data['access_token'],
|
|
45
44
|
refresh_token: data['refresh_token'],
|
|
46
|
-
token_type:
|
|
45
|
+
token_type: 'DPoP',
|
|
47
46
|
expires_in: (Time.parse(data['expires_at']) - Time.now).to_i,
|
|
48
47
|
scope: data['scope'],
|
|
49
48
|
sub: data['sub']
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'monitor'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
|
|
6
|
+
require_relative '../dpop/key_set'
|
|
6
7
|
require_relative '../pkce'
|
|
7
8
|
require_relative '../state'
|
|
8
9
|
|
|
@@ -20,14 +21,30 @@ module AuthProto
|
|
|
20
21
|
# @return [String]
|
|
21
22
|
attr_reader :session_id
|
|
22
23
|
|
|
23
|
-
# Random OAuth state token for callback validation.
|
|
24
|
-
# @return [String]
|
|
24
|
+
# Random OAuth state token for callback validation, cleared after authorization completes.
|
|
25
|
+
# @return [String, nil]
|
|
25
26
|
attr_reader :state_token
|
|
26
27
|
|
|
28
|
+
# Deadline for completing pending authorization, cleared after authorization completes.
|
|
29
|
+
# @return [Time, nil]
|
|
30
|
+
attr_reader :authorization_expires_at
|
|
31
|
+
|
|
27
32
|
# OAuth client identifier.
|
|
28
33
|
# @return [String]
|
|
29
34
|
attr_reader :client_id
|
|
30
35
|
|
|
36
|
+
# Client-authentication key ID, or nil for public clients.
|
|
37
|
+
# @return [String, nil]
|
|
38
|
+
attr_reader :signing_key_id
|
|
39
|
+
|
|
40
|
+
# RFC 7638 SHA-256 thumbprint of the client-authentication public key.
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
attr_reader :signing_key_thumbprint
|
|
43
|
+
|
|
44
|
+
# Private DPoP key belonging to this authorization session.
|
|
45
|
+
# @return [DPoP::KeySet]
|
|
46
|
+
attr_reader :dpop_key
|
|
47
|
+
|
|
31
48
|
# Space-separated requested scopes.
|
|
32
49
|
# @return [String]
|
|
33
50
|
attr_reader :scope
|
|
@@ -58,17 +75,39 @@ module AuthProto
|
|
|
58
75
|
# @param scope [String] requested space-separated scopes
|
|
59
76
|
# @param auth_server [Metadata::AuthorizationServer, nil] pre-resolved authorization server
|
|
60
77
|
# @param did [String, nil] pre-resolved account DID
|
|
78
|
+
# @param signing_key_id [String, nil] client-authentication key ID selected before PAR
|
|
79
|
+
# @param signing_key_thumbprint [String, nil] RFC 7638 SHA-256 thumbprint of that key
|
|
80
|
+
# @param dpop_key [DPoP::KeySet] session key; a fresh key is generated for new sessions by default
|
|
81
|
+
# @param authorization_expires_at [Time, nil] deadline for completing pending authorization
|
|
82
|
+
# @raise [ArgumentError] if the signing key ID, thumbprint or DPoP key is invalid
|
|
83
|
+
# @raise [DPoP::KeySet::KeyError] if DPoP key generation fails
|
|
61
84
|
# @raise [PKCE::Error] if PKCE generation fails
|
|
62
85
|
#
|
|
63
|
-
def initialize(client_id:, scope:, auth_server: nil, did: nil
|
|
86
|
+
def initialize(client_id:, scope:, auth_server: nil, did: nil, signing_key_id: nil, signing_key_thumbprint: nil,
|
|
87
|
+
dpop_key: DPoP::KeySet.new, authorization_expires_at: nil)
|
|
64
88
|
super() # Initialize MonitorMixin
|
|
65
89
|
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
unless signing_key_id.nil? || (signing_key_id.is_a?(String) && !signing_key_id.empty?)
|
|
91
|
+
raise ArgumentError, "signing_key_id must be a nonempty string or nil"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless signing_key_thumbprint.nil? || (signing_key_thumbprint.is_a?(String) && !signing_key_thumbprint.empty?)
|
|
95
|
+
raise ArgumentError, "signing_key_thumbprint must be a nonempty string or nil"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
raise ArgumentError, "dpop_key must be a DPoP::KeySet" unless dpop_key.is_a?(DPoP::KeySet)
|
|
99
|
+
|
|
100
|
+
@dpop_key = dpop_key
|
|
68
101
|
@client_id = client_id
|
|
102
|
+
@signing_key_id = signing_key_id&.dup&.freeze
|
|
103
|
+
@signing_key_thumbprint = signing_key_thumbprint&.dup&.freeze
|
|
69
104
|
@scope = scope
|
|
70
105
|
@auth_server = auth_server
|
|
71
106
|
@did = did
|
|
107
|
+
@authorization_expires_at = authorization_expires_at
|
|
108
|
+
|
|
109
|
+
@session_id = SecureRandom.uuid
|
|
110
|
+
@state_token = SecureRandom.urlsafe_base64(32)
|
|
72
111
|
|
|
73
112
|
pkce = PKCE.new
|
|
74
113
|
@pkce_verifier = pkce.verifier
|
|
@@ -77,6 +116,17 @@ module AuthProto
|
|
|
77
116
|
@tokens = nil
|
|
78
117
|
end
|
|
79
118
|
|
|
119
|
+
# Clears the OAuth state token and pending authorization deadline after successful authorization.
|
|
120
|
+
#
|
|
121
|
+
# @return [nil]
|
|
122
|
+
#
|
|
123
|
+
def clear_state_token
|
|
124
|
+
synchronize do
|
|
125
|
+
@state_token = nil
|
|
126
|
+
@authorization_expires_at = nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
80
130
|
# Binds this session to an authorization server.
|
|
81
131
|
#
|
|
82
132
|
# @param server [Metadata::AuthorizationServer] resolved authorization server
|
|
@@ -144,14 +194,24 @@ module AuthProto
|
|
|
144
194
|
end
|
|
145
195
|
end
|
|
146
196
|
|
|
197
|
+
# Returns true if this session has not completed authorization yet and doesn't contain tokens.
|
|
198
|
+
# @return [Boolean]
|
|
199
|
+
def pending?
|
|
200
|
+
synchronize do
|
|
201
|
+
@tokens.nil?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
147
205
|
# Whether this session has valid access tokens
|
|
148
206
|
# @return [Boolean]
|
|
149
|
-
def
|
|
207
|
+
def tokens_valid?
|
|
150
208
|
synchronize do
|
|
151
209
|
!@tokens.nil? && !@tokens.expired?
|
|
152
210
|
end
|
|
153
211
|
end
|
|
154
212
|
|
|
213
|
+
alias authorized? tokens_valid?
|
|
214
|
+
|
|
155
215
|
# Whether this session can refresh its tokens
|
|
156
216
|
# @return [Boolean]
|
|
157
217
|
def renewable?
|