statelydb 0.11.0 → 0.12.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 +4 -4
- data/lib/common/auth/auth0_token_provider.rb +158 -75
- data/lib/common/auth/interceptor.rb +1 -1
- data/lib/common/auth/token_provider.rb +7 -1
- data/lib/statelydb.rb +8 -2
- metadata +20 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 14c7769c562abe9ba0294a0d3ec4a1c7a620f9233f35209d39766331f3dd73e5
|
4
|
+
data.tar.gz: db2562de5584684ef9baf9de405b38112a43eb123aa3826ae4e563c6cfa0c1d0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4ba09f8776771bb4b6fc078e32255a272d986e6983737e1df9fe71bf95e685484ce6d20a91f16c96973bc89f7c9c79d1c0b9411f4a683430581bbc1e27a17468
|
7
|
+
data.tar.gz: cf612cf68f6c714ba7e79d328ab2b18236c711dbe806a6fb9714dd93eb163ac9899a415bb6c5d4861d24f103bc3844dff40cde27c4eca37eecf1ac3304298f93
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "async"
|
4
|
+
require "async/actor"
|
4
5
|
require "async/http/internet"
|
5
6
|
require "async/semaphore"
|
6
7
|
require "json"
|
@@ -12,6 +13,7 @@ LOGGER = Logger.new($stdout)
|
|
12
13
|
LOGGER.level = Logger::WARN
|
13
14
|
DEFAULT_GRANT_TYPE = "client_credentials"
|
14
15
|
|
16
|
+
# A module for Stately Cloud auth code
|
15
17
|
module StatelyDB
|
16
18
|
module Common
|
17
19
|
# A module for Stately Cloud auth code
|
@@ -21,102 +23,183 @@ module StatelyDB
|
|
21
23
|
# It will default to using the values of `STATELY_CLIENT_ID` and `STATELY_CLIENT_SECRET` if
|
22
24
|
# no credentials are explicitly passed and will throw an error if none are found.
|
23
25
|
class Auth0TokenProvider < TokenProvider
|
24
|
-
# @param [
|
26
|
+
# @param [Endpoint] domain The domain of the OAuth server
|
25
27
|
# @param [String] audience The OAuth Audience for the token
|
26
28
|
# @param [String] client_secret The StatelyDB client secret credential
|
27
29
|
# @param [String] client_id The StatelyDB client ID credential
|
28
30
|
def initialize(
|
29
|
-
|
31
|
+
domain: "https://oauth.stately.cloud",
|
30
32
|
audience: "api.stately.cloud",
|
31
33
|
client_secret: ENV.fetch("STATELY_CLIENT_SECRET"),
|
32
34
|
client_id: ENV.fetch("STATELY_CLIENT_ID")
|
33
35
|
)
|
34
36
|
super()
|
35
|
-
@
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
@
|
40
|
-
@pending_refresh = nil
|
41
|
-
@timer = nil
|
42
|
-
|
43
|
-
Async do |_task|
|
44
|
-
refresh_token
|
45
|
-
end
|
46
|
-
|
47
|
-
# need a weak ref to ourself or the GC will never run the finalizer
|
48
|
-
ObjectSpace.define_finalizer(WeakRef.new(self), finalize)
|
37
|
+
@actor = Async::Actor.new(Actor.new(domain: domain, audience: audience, client_secret: client_secret,
|
38
|
+
client_id: client_id))
|
39
|
+
# this initialization cannot happen in the constructor because it is async and must run on the event loop
|
40
|
+
# which is not available in the constructor
|
41
|
+
@actor.init
|
49
42
|
end
|
50
43
|
|
51
|
-
#
|
52
|
-
#
|
53
|
-
def
|
54
|
-
|
55
|
-
Thread.kill(@timer) unless @timer.nil?
|
56
|
-
}
|
44
|
+
# Close the token provider and kill any background operations
|
45
|
+
# This just invokes the close method on the actor which should do the cleanup
|
46
|
+
def close
|
47
|
+
@actor.close
|
57
48
|
end
|
58
49
|
|
59
50
|
# Get the current access token
|
60
51
|
# @return [String] The current access token
|
61
|
-
def
|
62
|
-
|
63
|
-
@access_token || refresh_token
|
52
|
+
def get_token(force: false)
|
53
|
+
@actor.get_token(force: force)
|
64
54
|
end
|
65
55
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
#
|
72
|
-
@
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
56
|
+
# Actor for managing the token refresh
|
57
|
+
# This is designed to be used with Async::Actor and run on a dedicated thread.
|
58
|
+
class Actor
|
59
|
+
# @param [Endpoint] domain The domain of the OAuth server
|
60
|
+
# @param [String] audience The OAuth Audience for the token
|
61
|
+
# @param [String] client_secret The StatelyDB client secret credential
|
62
|
+
# @param [String] client_id The StatelyDB client ID credential
|
63
|
+
def initialize(
|
64
|
+
domain: "https://oauth.stately.cloud",
|
65
|
+
audience: "api.stately.cloud",
|
66
|
+
client_secret: ENV.fetch("STATELY_CLIENT_SECRET"),
|
67
|
+
client_id: ENV.fetch("STATELY_CLIENT_ID")
|
68
|
+
)
|
69
|
+
super()
|
70
|
+
@client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(domain))
|
71
|
+
@client_id = client_id
|
72
|
+
@client_secret = client_secret
|
73
|
+
@audience = audience
|
81
74
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
@
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
75
|
+
@access_token = nil
|
76
|
+
@expires_at_secs = nil
|
77
|
+
@pending_refresh = nil
|
78
|
+
end
|
79
|
+
|
80
|
+
# Initialize the actor. This runs on the actor thread which means
|
81
|
+
# we can dispatch async operations here.
|
82
|
+
def init
|
83
|
+
refresh_token
|
84
|
+
end
|
85
|
+
|
86
|
+
# Close the token provider and kill any background operations
|
87
|
+
def close
|
88
|
+
@scheduled&.stop
|
89
|
+
@client&.close
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get the current access token
|
93
|
+
# @param [Boolean] force Whether to force a refresh of the token
|
94
|
+
# @return [String] The current access token
|
95
|
+
def get_token(force: false)
|
96
|
+
if force
|
97
|
+
@access_token = nil
|
98
|
+
@expires_at_secs = nil
|
99
|
+
else
|
100
|
+
token, ok = valid_access_token
|
101
|
+
return token if ok
|
102
|
+
end
|
103
|
+
|
104
|
+
refresh_token.wait
|
105
|
+
end
|
106
|
+
|
107
|
+
# Get the current access token and whether it is valid
|
108
|
+
# @return [Array] The current access token and whether it is valid
|
109
|
+
def valid_access_token
|
110
|
+
return "", false if @access_token.nil?
|
111
|
+
return "", false if @expires_at_secs.nil?
|
112
|
+
return "", false if @expires_at_secs < Time.now.to_i
|
113
|
+
|
114
|
+
[@access_token, true]
|
115
|
+
end
|
116
|
+
|
117
|
+
# Refresh the access token
|
118
|
+
# @return [string] The new access token
|
119
|
+
def refresh_token
|
120
|
+
Async do
|
121
|
+
# we use an Async::Condition to dedupe multiple requests here
|
122
|
+
# if the condition exists, we wait on it to complete
|
123
|
+
# otherwise we create a condition, make the request, then signal the condition with the result
|
124
|
+
# If there is an error then we signal that instead so we can raise it for the waiters.
|
125
|
+
if @pending_refresh.nil?
|
126
|
+
begin
|
127
|
+
@pending_refresh = Async::Condition.new
|
128
|
+
new_access_token = refresh_token_impl.wait
|
129
|
+
# now broadcast the new token to any waiters
|
130
|
+
@pending_refresh.signal(new_access_token)
|
131
|
+
new_access_token
|
132
|
+
rescue StandardError => e
|
133
|
+
@pending_refresh.signal(e)
|
134
|
+
raise e
|
135
|
+
ensure
|
136
|
+
# delete the condition to restart the process
|
137
|
+
@pending_refresh = nil
|
138
|
+
end
|
139
|
+
else
|
140
|
+
res = @pending_refresh.wait
|
141
|
+
# if the refresh result is an error, re-raise it.
|
142
|
+
# otherwise return the token
|
143
|
+
raise res if res.is_a?(StandardError)
|
144
|
+
|
145
|
+
res
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Refresh the access token implementation
|
151
|
+
# @return [String] The new access token
|
152
|
+
def refresh_token_impl
|
153
|
+
Async do
|
154
|
+
resp_data = make_auth0_request
|
155
|
+
|
156
|
+
new_access_token = resp_data["access_token"]
|
157
|
+
new_expires_in_secs = resp_data["expires_in"]
|
158
|
+
new_expires_at_secs = Time.now.to_i + new_expires_in_secs
|
159
|
+
if @expires_at_secs.nil? || new_expires_at_secs > @expires_at_secs
|
160
|
+
|
161
|
+
@access_token = new_access_token
|
162
|
+
@expires_at_secs = new_expires_at_secs
|
163
|
+
else
|
164
|
+
|
165
|
+
new_access_token = @access_token
|
166
|
+
new_expires_in_secs = @expires_at_secs - Time.now.to_i
|
167
|
+
end
|
168
|
+
|
169
|
+
# Schedule a refresh of the token ahead of the expiry time
|
170
|
+
# Calculate a random multiplier between 0.9 and 0.95 to to apply to the expiry
|
104
171
|
# so that we refresh in the background ahead of expiration, but avoid
|
105
172
|
# multiple processes hammering the service at the same time.
|
106
|
-
jitter = (Random.rand * 0.
|
107
|
-
|
108
|
-
|
109
|
-
|
173
|
+
jitter = (Random.rand * 0.05) + 0.9
|
174
|
+
delay_secs = new_expires_in_secs * jitter
|
175
|
+
|
176
|
+
# do this on the fiber scheduler (the root scheduler) to avoid infinite recursion
|
177
|
+
@scheduled ||= Fiber.scheduler.async do
|
178
|
+
# Kernel.sleep is non-blocking if Ruby 3.1+ and Async 2+
|
179
|
+
# https://github.com/socketry/async/issues/305#issuecomment-1945188193
|
180
|
+
sleep(delay_secs)
|
181
|
+
refresh_token
|
182
|
+
@scheduled = nil
|
183
|
+
end
|
184
|
+
|
185
|
+
new_access_token
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def make_auth0_request
|
190
|
+
headers = [["content-type", "application/json"]]
|
191
|
+
body = JSON.dump({ "client_id" => @client_id, client_secret: @client_secret, audience: @audience,
|
192
|
+
grant_type: DEFAULT_GRANT_TYPE })
|
193
|
+
Sync do
|
194
|
+
# TODO: Wrap this in a retry loop and parse errors like we
|
195
|
+
# do in the Go SDK.
|
196
|
+
response = @client.post("/oauth/token", headers, body)
|
197
|
+
raise "Auth request failed" if response.status != 200
|
198
|
+
|
199
|
+
JSON.parse(response.read)
|
200
|
+
ensure
|
201
|
+
response&.close
|
110
202
|
end
|
111
|
-
@pending_refresh = nil
|
112
|
-
resp_data["access_token"]
|
113
|
-
rescue StandardError => e
|
114
|
-
# set the token to nil so that it will
|
115
|
-
# be refreshed on the next get
|
116
|
-
@access_token = nil
|
117
|
-
LOGGER.warn(e)
|
118
|
-
ensure
|
119
|
-
client.close
|
120
203
|
end
|
121
204
|
end
|
122
205
|
end
|
@@ -8,8 +8,14 @@ module StatelyDB
|
|
8
8
|
# for individual token provider implementations
|
9
9
|
class TokenProvider
|
10
10
|
# Get the current access token
|
11
|
+
# @param [Boolean] force Whether to force a refresh of the token
|
11
12
|
# @return [String] The current access token
|
12
|
-
def
|
13
|
+
def get_token(force: false) # rubocop:disable Lint/UnusedMethodArgument
|
14
|
+
raise "Not Implemented"
|
15
|
+
end
|
16
|
+
|
17
|
+
# Close the token provider and kill any background operations
|
18
|
+
def close
|
13
19
|
raise "Not Implemented"
|
14
20
|
end
|
15
21
|
end
|
data/lib/statelydb.rb
CHANGED
@@ -50,18 +50,24 @@ module StatelyDB
|
|
50
50
|
end
|
51
51
|
|
52
52
|
endpoint = self.class.make_endpoint(endpoint:, region:)
|
53
|
-
channel = Common::Net.new_channel(endpoint:)
|
53
|
+
@channel = Common::Net.new_channel(endpoint:)
|
54
|
+
@token_provider = token_provider
|
54
55
|
|
55
56
|
auth_interceptor = Common::Auth::Interceptor.new(token_provider:)
|
56
57
|
error_interceptor = Common::ErrorInterceptor.new
|
57
58
|
|
58
|
-
@stub = Stately::Db::DatabaseService::Stub.new(nil, nil, channel_override: channel,
|
59
|
+
@stub = Stately::Db::DatabaseService::Stub.new(nil, nil, channel_override: @channel,
|
59
60
|
interceptors: [error_interceptor, auth_interceptor])
|
60
61
|
@store_id = store_id.to_i
|
61
62
|
@schema = schema
|
62
63
|
@allow_stale = false
|
63
64
|
end
|
64
65
|
|
66
|
+
def close
|
67
|
+
@channel&.close
|
68
|
+
@token_provider&.close
|
69
|
+
end
|
70
|
+
|
65
71
|
# Set whether to allow stale results for all operations with this client. This produces a new client
|
66
72
|
# with the allow_stale flag set.
|
67
73
|
# @param allow_stale [Boolean] whether to allow stale results
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: statelydb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stately Cloud, Inc.
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-12-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: async
|
@@ -16,28 +16,42 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - '='
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 2.
|
19
|
+
version: 2.21.1
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - '='
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: 2.
|
26
|
+
version: 2.21.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: async-actor
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.1.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.1.1
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: async-http
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
30
44
|
requirements:
|
31
45
|
- - '='
|
32
46
|
- !ruby/object:Gem::Version
|
33
|
-
version: 0.
|
47
|
+
version: 0.85.0
|
34
48
|
type: :runtime
|
35
49
|
prerelease: false
|
36
50
|
version_requirements: !ruby/object:Gem::Requirement
|
37
51
|
requirements:
|
38
52
|
- - '='
|
39
53
|
- !ruby/object:Gem::Version
|
40
|
-
version: 0.
|
54
|
+
version: 0.85.0
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: grpc
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|