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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +108 -5
  4. data/examples/confidential_client/README.md +10 -0
  5. data/examples/confidential_client/app.rb +8 -7
  6. data/examples/confidential_client/scripts/generate_keys.rb +1 -1
  7. data/examples/minisky_console/Gemfile.lock +2 -2
  8. data/examples/minisky_console/README.md +20 -1
  9. data/examples/minisky_console/localhost.rb +5 -3
  10. data/examples/minisky_console/production.rb +9 -3
  11. data/lib/authproto/client.rb +73 -90
  12. data/lib/authproto/configuration.rb +37 -16
  13. data/lib/authproto/dpop/client.rb +105 -40
  14. data/lib/authproto/dpop/{key_manager.rb → key_set.rb} +54 -120
  15. data/lib/authproto/dpop/nonce_manager.rb +33 -20
  16. data/lib/authproto/dpop/proof_generator.rb +17 -32
  17. data/lib/authproto/dpop.rb +19 -0
  18. data/lib/authproto/http_helpers.rb +85 -0
  19. data/lib/authproto/metadata/authorization_server.rb +12 -6
  20. data/lib/authproto/metadata/client_metadata.rb +18 -5
  21. data/lib/authproto/metadata/resource_server.rb +4 -2
  22. data/lib/authproto/minisky.rb +36 -34
  23. data/lib/authproto/par/client.rb +22 -73
  24. data/lib/authproto/par/client_assertion.rb +47 -16
  25. data/lib/authproto/par/request.rb +4 -8
  26. data/lib/authproto/par.rb +2 -1
  27. data/lib/authproto/serialization/session.rb +31 -13
  28. data/lib/authproto/serialization/token_set.rb +1 -2
  29. data/lib/authproto/state/session.rb +66 -6
  30. data/lib/authproto/state/session_manager.rb +95 -21
  31. data/lib/authproto/state/token_set.rb +16 -16
  32. data/lib/authproto/state.rb +1 -1
  33. data/lib/authproto/storage/key_builder.rb +3 -11
  34. data/lib/authproto/storage/memory.rb +8 -2
  35. data/lib/authproto/token/refresh.rb +26 -59
  36. data/lib/authproto/validated_uri.rb +23 -0
  37. data/lib/authproto/version.rb +1 -1
  38. data/lib/authproto.rb +0 -1
  39. data/sig/authproto/client.rbs +5 -7
  40. data/sig/authproto/dependencies.rbs +2 -0
  41. data/sig/authproto/dpop/client.rbs +11 -4
  42. data/sig/authproto/dpop/{key_manager.rbs → key_set.rbs} +9 -11
  43. data/sig/authproto/dpop/nonce_manager.rbs +4 -4
  44. data/sig/authproto/dpop/proof_generator.rbs +5 -10
  45. data/sig/authproto/dpop.rbs +6 -0
  46. data/sig/authproto/http_helpers.rbs +7 -0
  47. data/sig/authproto/localhost_server.rbs +1 -1
  48. data/sig/authproto/metadata/authorization_server.rbs +1 -0
  49. data/sig/authproto/metadata/client_metadata.rbs +2 -1
  50. data/sig/authproto/metadata/resource_server.rbs +1 -0
  51. data/sig/authproto/minisky_oauth.rbs +5 -2
  52. data/sig/authproto/par/client.rbs +4 -8
  53. data/sig/authproto/par/client_assertion.rbs +4 -0
  54. data/sig/authproto/par/request.rbs +0 -9
  55. data/sig/authproto/state/session.rbs +10 -3
  56. data/sig/authproto/state/session_manager.rbs +8 -3
  57. data/sig/authproto/state/token_set.rbs +1 -2
  58. data/sig/authproto/storage/key_builder.rbs +1 -2
  59. data/sig/authproto/token/refresh.rbs +3 -4
  60. data/sig/authproto.rbs +5 -2
  61. metadata +14 -19
  62. data/.rspec +0 -1
  63. data/.rubocop.yml +0 -31
  64. data/.tm_properties +0 -1
  65. data/.yardopts +0 -5
  66. data/AGENTS.md +0 -80
  67. data/Rakefile +0 -8
  68. data/examples/confidential_client/.gitignore +0 -6
  69. data/examples/minisky_console/.gitignore +0 -2
  70. data/lib/authproto/serialization/dpop_key.rb +0 -38
  71. data/misc/yard_attributes.rb +0 -34
  72. data/sig/authproto/serialization/dpop_key.rbs +0 -14
@@ -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
- # response = client.submit(request)
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
- # Try the initial request
95
- response = make_request(request)
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
- handle_error_response(response)
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
- # Generate DPoP proof for this request
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: 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 process_response(response)
212
- raise Error, "unexpected response status: #{response[:status]}" unless response[:status] == 201
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.split(';', 2).first.to_s.strip.casecmp?('application/json')
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
- @client_id = client_id
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 five minutes
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 JWK import or JWT signing fails
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: 300)
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: 'ES256',
83
+ alg: ALGORITHM,
63
84
  typ: 'JWT',
64
85
  kid: @signing_key.fields['kid']
65
86
  }
66
87
 
67
- key = JWT::JWK.import(@signing_key.to_map.to_h)
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'] != 'ES256') ||
97
- (data.key?('use') && data['use'] != 'sig') ||
98
- (data.key?('key_ops') && (!data['key_ops'].is_a?(Array) || !data['key_ops'].include?('sign')))
99
- raise Error, "signing_key must be active and permit ES256 signing"
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
- imported = JWT::JWK.import(data)
105
- proof = JWT.encode({}, imported, 'ES256')
106
- JWT.decode(proof, imported.public_key, true, algorithms: ['ES256'])
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, PKCE, scope or client authentication are invalid
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 or authentication/PKCE/scope values are invalid
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
- # response = par.submit(request)
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
- AuthProto::State::Session.new(
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
- ).tap do |session|
52
- # Set readonly attributes
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
-
58
- # Set tokens if present
59
- session.tokens = deserialize_token_set(data['tokens']) if data['tokens']
60
- end
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: data['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
- @session_id = SecureRandom.uuid
67
- @state_token = SecureRandom.urlsafe_base64(32)
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 authorized?
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?