volcano-sdk 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b992adf24aad6b7edea16323e1f9b21c8db08cefe1380cffaf12c99ed436baf4
4
- data.tar.gz: 3770340779a1a5cf0b985b6290a185223632f85863e56ab33c4f8f443ce02f0e
3
+ metadata.gz: 255837a17bdecec1c4973e91d21885299b563a70adced0adf20fbbeb5bd8020d
4
+ data.tar.gz: edc269875f2132e437787412be884aeb0149acb81fb48397b750271b970afaa6
5
5
  SHA512:
6
- metadata.gz: 8bd2583130b36e6ede81cdb096cfe97cff10aa9aebe8fb086cff5dd1d13c8883f6bc196b9d528c4e437fb7533df568aab7fce759d0a79f134983b825b730f9d7
7
- data.tar.gz: d5118276bfe5a10d157e637643827a07586cba53d632b5f966501f3ffa9bf5562c1d1a682d8286026f0109f77b19cf701cd3a536d0fc419badf7e34e8d9f3a89
6
+ metadata.gz: b5e51ede8b8946bb3aca753f0e0661d5e93c9ca6c3dccae7bd5dc78a34b64cf645138a2edd09f6c0c402a920505087410b12d14ce5485ae6ae46630295be21cf
7
+ data.tar.gz: 38ca82b2e5e0ca112e2be5d7de5ac4719686622524e162d3db6526d0dcd4462d640ca1df17012265aa87243122176ee5f06083f3b7a8ea4e1090e04569b13711
data/README.md CHANGED
@@ -378,6 +378,17 @@ Success returns `nil`. The reset revokes the recovered account's existing
378
378
  sessions and does not sign it in. The client keeps any unrelated local session
379
379
  unchanged; sign in with the new password when the reset flow completes.
380
380
 
381
+ ### Start with a supplied access token
382
+
383
+ Pass `access_token` to `Volcano::Client.new` to start without a refresh token or
384
+ known user identity. Construction makes no request and leaves `refresh_token`,
385
+ `user_id`, and `user` as `nil`. `auth.user` validates and caches the profile
386
+ without changing credentials. Without a refresh token, `refresh_session` raises
387
+ `Volcano::Error::AuthenticationError`. `sign_out` revokes the server session using
388
+ the access token and clears local state. Supply `refresh_token` alongside
389
+ `access_token` to enable refresh.
390
+ See the [token bootstrap example](https://github.com/Kong/volcano-sdk-ruby/blob/main/docs/README.md#use-a-supplied-access-token).
391
+
381
392
  ### Adopt an existing session
382
393
 
383
394
  ```ruby
@@ -429,10 +440,11 @@ client.auth.sign_out
429
440
  raise "still signed in" if client.auth.current_session
430
441
  ```
431
442
 
432
- `sign_out` revokes the current refresh token and clears the captured in-memory
443
+ `sign_out` uses the access-token session when available, even if a refresh token was supplied.
444
+ It revokes the captured session and clears the captured in-memory
433
445
  session. It succeeds without a request when no session exists. If revocation
434
- fails, the SDK still clears that session and raises the typed error. A session
435
- established while sign-out is pending remains current.
446
+ fails, the SDK still clears that session and raises the typed error. A concurrent
447
+ refresh of the same session is cleared; a separate sign-in or adoption remains current.
436
448
 
437
449
  ### Invoke a function
438
450
 
data/lib/volcano/auth.rb CHANGED
@@ -34,16 +34,6 @@ module Volcano
34
34
  sign_in_for_generation(email:, password:, generation:)
35
35
  end
36
36
 
37
- def sign_out
38
- generation, current = @client.capture_session
39
- return unless current
40
-
41
- error = revocation_error(current.refresh_token)
42
- raise Error::SessionChangedError, cause: error unless @client.clear_session_if_current?(generation)
43
-
44
- raise error if error
45
- end
46
-
47
37
  private
48
38
 
49
39
  def sign_in_for_generation(email:, password:, generation:)
@@ -66,22 +56,6 @@ module Volcano
66
56
  end
67
57
  end
68
58
 
69
- def logout_response(refresh_token)
70
- Transport.invoke do
71
- @transport.auth_logout(
72
- authorization: @client.anon_token,
73
- refresh_token: refresh_token
74
- )
75
- end
76
- end
77
-
78
- def revocation_error(refresh_token)
79
- Transport.body(logout_response(refresh_token), 204)
80
- nil
81
- rescue Error::VolcanoError => e
82
- e
83
- end
84
-
85
59
  def build_session(payload)
86
60
  owned_session(
87
61
  access_token: payload.fetch('access_token'),
@@ -78,6 +78,9 @@ module Volcano
78
78
  end
79
79
 
80
80
  def perform_refresh(binding, notifications)
81
+ raise Error::AuthenticationError, 'No refresh token' unless binding.last.refresh_token
82
+
83
+ SessionCredentials.validate_refresh_source(binding.last)
81
84
  session = refreshed_session(binding, notifications)
82
85
  stored = @client.store_session_if_current?(
83
86
  session, binding.first, event: :token_refreshed, notifications: notifications
@@ -86,7 +89,11 @@ module Volcano
86
89
  end
87
90
 
88
91
  def refreshed_session(binding, notifications)
89
- owned_complete_session(build_session(refresh_payload(binding, notifications)))
92
+ parse_refresh_session(refresh_payload(binding, notifications))
93
+ end
94
+
95
+ def parse_refresh_session(payload)
96
+ owned_complete_session(build_session(payload))
90
97
  rescue KeyError, TypeError, NoMethodError, ArgumentError => e
91
98
  raise Error::TransportError, INCOMPLETE_SESSION, cause: e
92
99
  end
@@ -124,16 +124,7 @@ module Volcano
124
124
  end
125
125
 
126
126
  def access_token_session_id(access_token)
127
- parts = access_token.split('.')
128
- return unless parts.length == 3
129
-
130
- encoded = parts.fetch(1).tr('-_', '+/')
131
- padding = '=' * (-encoded.length % 4)
132
- payload = JSON.parse((encoded + padding).unpack1('m0'))
133
- session_id = payload['session_id']
134
- session_id if session_id.is_a?(String) && !session_id.empty?
135
- rescue ArgumentError, JSON::ParserError
136
- nil
127
+ SessionCredentials.session_id(access_token)
137
128
  end
138
129
  end
139
130
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Revokes the captured server session and clears its local lineage.
5
+ class Auth
6
+ def sign_out
7
+ binding = @client.capture_session_binding
8
+ return unless binding.last
9
+
10
+ notifications = []
11
+ @refresh_lock.synchronize { sign_out_captured(binding, notifications) }
12
+ ensure
13
+ notifications&.each(&:call)
14
+ end
15
+
16
+ private
17
+
18
+ def sign_out_captured(binding, notifications)
19
+ generation, lineage, current = revocation_binding(binding)
20
+ error = revocation_error(current)
21
+ lineage = nil unless access_token_session_id(current.access_token)
22
+ cleared = @client.clear_session_if_current?(generation, lineage: lineage, notifications: notifications)
23
+ raise Error::SessionChangedError, cause: error unless cleared
24
+
25
+ raise error if error
26
+ end
27
+
28
+ def revocation_binding(binding)
29
+ active = @client.capture_session_binding
30
+ active.last && active[1] == binding[1] ? active : binding
31
+ end
32
+
33
+ def logout_response(refresh_token)
34
+ Transport.invoke do
35
+ @transport.auth_logout(
36
+ authorization: @client.anon_token,
37
+ refresh_token: refresh_token
38
+ )
39
+ end
40
+ end
41
+
42
+ def revocation_error(session)
43
+ session_id = access_token_session_id(session.access_token)
44
+ return revoke_access_session(session, session_id) if session_id
45
+ return unless session.refresh_token
46
+
47
+ Transport.body(logout_response(session.refresh_token), 204)
48
+ nil
49
+ rescue Error::VolcanoError => e
50
+ e
51
+ end
52
+
53
+ def revoke_access_session(session, session_id)
54
+ error = delete_session_error(session.access_token, session_id)
55
+ return error unless error&.status == 401 && session.refresh_token
56
+
57
+ refreshed = parse_refresh_session(Transport.body(refresh_response(session.refresh_token), 200))
58
+ SessionCredentials.validate_refresh(session, refreshed)
59
+ delete_session_error(refreshed.access_token, session_id)
60
+ end
61
+ end
62
+ end
@@ -24,11 +24,11 @@ module Volcano
24
24
  CALLBACK_FAILURES = [Exception].freeze
25
25
  private_constant :CALLBACK_FAILURES
26
26
 
27
- def initialize
27
+ def initialize(session: nil)
28
28
  @mutex = Mutex.new
29
29
  @generation = 0
30
30
  @lineage = 0
31
- @session = nil
31
+ @session = session
32
32
  @callbacks = {}
33
33
  @next_callback_id = 0
34
34
  @notifications = []
@@ -48,9 +48,9 @@ module Volcano
48
48
  drain_notifications if dispatch
49
49
  end
50
50
 
51
- def store_if_current?(session, generation, event: :signed_in, notifications: nil)
51
+ def store_if_current?(session, generation, event: :signed_in, notifications: nil, lineage: nil)
52
52
  dispatch = @mutex.synchronize do
53
- return false unless generation == @generation
53
+ return false unless lineage.nil? ? generation == @generation : lineage == @lineage
54
54
 
55
55
  enqueue_notification(replace(session, event), event, session)
56
56
  end
@@ -63,9 +63,8 @@ module Volcano
63
63
  def update_user_if_current?(user, generation)
64
64
  @mutex.synchronize do
65
65
  return false unless generation == @generation && @session
66
- raise Error::AuthenticationError, 'Profile belongs to a different user' unless user['id'] == @session.user_id
67
66
 
68
- @session = Session.new(**@session.to_h, user: user)
67
+ @session = SessionCredentials.with_user(@session, user)
69
68
  true
70
69
  end
71
70
  end
@@ -82,6 +81,7 @@ module Volcano
82
81
  private
83
82
 
84
83
  def replace(session, event)
84
+ SessionCredentials.validate_refresh(@session, session) if event == :token_refreshed
85
85
  @session = session
86
86
  @generation += 1
87
87
  @lineage += 1 unless event == :token_refreshed
@@ -19,13 +19,14 @@ module Volcano
19
19
  api_url: 'https://api.volcano.dev',
20
20
  service_key: nil,
21
21
  timeout: 60,
22
- **adapters
22
+ **options
23
23
  )
24
- transport, socket_factory, reconnect_delay = extract_adapters(adapters)
24
+ session = SessionCredentials.build(options.delete(:access_token), options.delete(:refresh_token))
25
+ transport, socket_factory, reconnect_delay = extract_adapters(options)
25
26
  @api_url = api_url.delete_suffix('/')
26
27
  @anon_key = anon_key
27
28
  @service_key = service_key
28
- @auth_state = AuthState.new
29
+ @auth_state = AuthState.new(session: session)
29
30
  @transport = transport || GeneratedTransport.new(api_url: @api_url, timeout: timeout)
30
31
  initialize_facades(socket_factory, reconnect_delay)
31
32
  end
@@ -81,8 +82,8 @@ module Volcano
81
82
  @auth_state.store_if_current?(session, generation, event: event, notifications: notifications)
82
83
  end
83
84
 
84
- def clear_session_if_current?(generation, event: :signed_out, notifications: nil)
85
- @auth_state.store_if_current?(nil, generation, event: event, notifications: notifications)
85
+ def clear_session_if_current?(generation, event: :signed_out, notifications: nil, lineage: nil)
86
+ @auth_state.store_if_current?(nil, generation, event: event, notifications: notifications, lineage: lineage)
86
87
  end
87
88
 
88
89
  def subscribe_auth_state_change(...)
@@ -13,17 +13,18 @@ module Volcano
13
13
  session = session_for_lineage(session_lineage)
14
14
  protocol = build_protocol(socket)
15
15
  result = protocol.connect(token: session.access_token)
16
- session = session_for_lineage(session_lineage)
17
- activate_protocol(protocol, session, result)
16
+ session_for_lineage(session_lineage)
17
+ activate_protocol(protocol, session_lineage, result)
18
18
  rescue StandardError => e
19
19
  handle_connection_failure(socket, e)
20
20
  end
21
21
 
22
22
  def active_session_lineage
23
23
  _, lineage, session = @client.capture_session_binding
24
- return lineage if session
24
+ raise Error::AuthenticationError, 'No active session' unless session
25
25
 
26
- raise Error::AuthenticationError, 'No active session'
26
+ session_for_lineage(lineage)
27
+ lineage
27
28
  end
28
29
 
29
30
  def session_for_lineage(expected_lineage)
@@ -33,9 +34,9 @@ module Volcano
33
34
  raise Error::SessionChangedError
34
35
  end
35
36
 
36
- def activate_protocol(protocol, session, result)
37
+ def activate_protocol(protocol, session_lineage, result)
37
38
  @protocol = protocol
38
- @protocol_user_id = session.user_id
39
+ @protocol_session_lineage = session_lineage
39
40
  protocol_connected(result)
40
41
  protocol
41
42
  end
@@ -55,7 +56,7 @@ module Volcano
55
56
  ensure
56
57
  @manual_disconnect = false
57
58
  @channels.each_value(&:mark_closed)
58
- @protocol_user_id = nil
59
+ @protocol_session_lineage = nil
59
60
  end
60
61
 
61
62
  def handle_connection_failure(socket, error)
@@ -117,7 +117,7 @@ module Volcano
117
117
  def reset_after_protocol_loss
118
118
  protocol = @protocol
119
119
  @protocol = nil
120
- @protocol_user_id = nil
120
+ @protocol_session_lineage = nil
121
121
  @channels.each_value { |channel| channel.protocol_lost(protocol) } if protocol
122
122
  end
123
123
  end
@@ -30,15 +30,14 @@ module Volcano
30
30
 
31
31
  def capture_protocol_session
32
32
  generation, lineage, session = capture_session_binding
33
- return [generation, lineage, session] if session && session.user_id == @protocol_user_id
33
+ return [generation, lineage, session] if session && lineage == @protocol_session_lineage
34
34
 
35
35
  raise Error::SessionChangedError
36
36
  end
37
37
 
38
38
  def access_token_for_protocol_lineage(expected_lineage)
39
39
  _, lineage, session = capture_session_binding
40
- return session.access_token if session && lineage == expected_lineage &&
41
- session.user_id == @protocol_user_id
40
+ return session.access_token if session && lineage == expected_lineage && lineage == @protocol_session_lineage
42
41
 
43
42
  raise Error::SessionChangedError
44
43
  end
@@ -73,7 +73,7 @@ module Volcano
73
73
  @channels = {}
74
74
  @connection_callbacks = { connect: {}, disconnect: {}, error: {} }
75
75
  @next_connection_callback_id = 0
76
- @protocol_user_id = nil
76
+ @protocol_session_lineage = nil
77
77
  @reconnect_task = nil
78
78
  @closed = false
79
79
  end
@@ -9,9 +9,78 @@ module Volcano
9
9
  end
10
10
  private_constant :SESSION_USER_CODER
11
11
 
12
- # In-memory credentials with an optional, unverified local user snapshot.
12
+ # Owns credential snapshots and preserves validated identity during refresh.
13
+ module SessionCredentials
14
+ def self.build(access_token, refresh_token)
15
+ raise ArgumentError, 'refresh_token requires access_token' if access_token.nil? && !refresh_token.nil?
16
+ return if access_token.nil?
17
+
18
+ Session.new(access_token: credential(access_token, :access_token),
19
+ refresh_token: credential(refresh_token, :refresh_token))
20
+ end
21
+
22
+ def self.credential(value, name)
23
+ return if value.nil?
24
+
25
+ raise ArgumentError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
26
+
27
+ value.dup.freeze
28
+ end
29
+ private_class_method :credential
30
+
31
+ def self.validate_refresh_source(current)
32
+ return if current.user_id || session_id(current.access_token)
33
+
34
+ raise Error::AuthenticationError, 'Cannot refresh unknown identity without a session identifier'
35
+ end
36
+
37
+ def self.validate_refresh(current, refreshed)
38
+ return unless current
39
+
40
+ validate_refresh_source(current)
41
+ expected = session_id(current.access_token)
42
+ if expected && expected != session_id(refreshed&.access_token)
43
+ raise Error::AuthenticationError, 'Refreshed credentials belong to a different server session'
44
+ end
45
+ return unless current.user_id && current.user_id != refreshed&.user_id
46
+
47
+ raise Error::AuthenticationError, 'Refreshed session belongs to a different user'
48
+ end
49
+
50
+ # Untrusted continuity constraint, never authenticated user identity.
51
+ def self.session_id(access_token)
52
+ payload = token_payload(access_token)
53
+ return unless payload.is_a?(Hash)
54
+
55
+ value = payload['session_id']
56
+ value.downcase if value.is_a?(String) && value.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i)
57
+ end
58
+
59
+ def self.token_payload(access_token)
60
+ return unless access_token.is_a?(String)
61
+
62
+ parts = access_token.split('.')
63
+ return unless parts.length == 3
64
+
65
+ encoded = parts.fetch(1).tr('-_', '+/')
66
+ JSON.parse((encoded + ('=' * (-encoded.length % 4))).unpack1('m0'))
67
+ rescue ArgumentError, JSON::ParserError
68
+ nil
69
+ end
70
+ private_class_method :token_payload
71
+
72
+ def self.with_user(session, user)
73
+ user_id = (session.user_id || user.fetch('id')).dup.freeze
74
+ raise Error::AuthenticationError, 'Profile belongs to a different user' unless user['id'] == user_id
75
+
76
+ Session.new(**session.to_h, user_id: user_id, user: user)
77
+ end
78
+ end
79
+ private_constant :SessionCredentials
80
+
81
+ # Local credentials; refresh credentials and user identity may be unknown.
13
82
  Session = Data.define(:access_token, :refresh_token, :user_id, :user) do
14
- def initialize(access_token:, refresh_token:, user_id:, user: nil)
83
+ def initialize(access_token:, refresh_token: nil, user_id: nil, user: nil)
15
84
  super(access_token:, refresh_token:, user_id:, user: immutable_user(user))
16
85
  end
17
86
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Volcano
4
- VERSION = '0.7.2'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/volcano.rb CHANGED
@@ -13,6 +13,7 @@ require_relative 'volcano/redaction'
13
13
  require_relative 'volcano/transport'
14
14
  require_relative 'volcano/generated_transport'
15
15
  require_relative 'volcano/auth'
16
+ require_relative 'volcano/auth_sign_out'
16
17
  require_relative 'volcano/auth_refresh'
17
18
  require_relative 'volcano/auth_sign_up'
18
19
  require_relative 'volcano/sign_up_result'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: volcano-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kong
@@ -145,6 +145,7 @@ files:
145
145
  - lib/volcano/auth_reset_password.rb
146
146
  - lib/volcano/auth_reset_password_for_email.rb
147
147
  - lib/volcano/auth_sessions.rb
148
+ - lib/volcano/auth_sign_out.rb
148
149
  - lib/volcano/auth_sign_up.rb
149
150
  - lib/volcano/auth_state.rb
150
151
  - lib/volcano/auth_update_user.rb