spiffe-workload 0.2.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +17 -0
- data/README.md +342 -0
- data/lib/spiffe/version.rb +5 -0
- data/lib/spiffe/workload/client.rb +348 -0
- data/lib/spiffe/workload/grpc_stub.rb +122 -0
- data/lib/spiffe/workload/http_client.rb +67 -0
- data/lib/spiffe/workload/jwt_svid.rb +75 -0
- data/lib/spiffe/workload/messages.rb +102 -0
- data/lib/spiffe/workload/proto_helper.rb +46 -0
- data/lib/spiffe/workload/service.rb +36 -0
- data/lib/spiffe/workload/tls_config.rb +43 -0
- data/lib/spiffe/workload/x509_svid.rb +93 -0
- data/lib/spiffe/workload_pb.rb +30 -0
- data/lib/spiffe/workload_services_pb.rb +51 -0
- data/lib/spiffe.rb +22 -0
- data/proto/spiffe/workload.proto +168 -0
- metadata +166 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'grpc'
|
|
4
|
+
require 'thread'
|
|
5
|
+
require_relative 'x509_svid'
|
|
6
|
+
require_relative 'jwt_svid'
|
|
7
|
+
require_relative '../workload_pb'
|
|
8
|
+
require_relative '../workload_services_pb'
|
|
9
|
+
|
|
10
|
+
module Spiffe
|
|
11
|
+
module Workload
|
|
12
|
+
# Client for SPIFFE Workload API
|
|
13
|
+
# Connects to SPIRE Agent via Unix domain socket and fetches SVIDs
|
|
14
|
+
class Client
|
|
15
|
+
DEFAULT_SOCKET_PATH = '/run/spire/sockets/agent.sock'
|
|
16
|
+
DEFAULT_TIMEOUT = 5 # seconds
|
|
17
|
+
|
|
18
|
+
attr_reader :socket_path
|
|
19
|
+
|
|
20
|
+
# @param socket_path [String] Path to SPIRE Agent Unix socket
|
|
21
|
+
# @param timeout [Integer] Connection timeout in seconds
|
|
22
|
+
def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT)
|
|
23
|
+
@socket_path = socket_path || ENV['SPIFFE_ENDPOINT_SOCKET']&.sub('unix://', '') || DEFAULT_SOCKET_PATH
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@x509_svid_cache = nil
|
|
27
|
+
@x509_bundles_cache = nil
|
|
28
|
+
@jwt_bundles_cache = nil
|
|
29
|
+
@rotation_thread = nil
|
|
30
|
+
@shutdown = false
|
|
31
|
+
|
|
32
|
+
validate_socket!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fetch X.509 SVID (blocking call, returns first response)
|
|
36
|
+
# @return [X509SVIDWrapper] The default X.509 SVID
|
|
37
|
+
# @raise [Spiffe::Error] If fetch fails
|
|
38
|
+
def x509_svid
|
|
39
|
+
ensure_x509_svid_stream_started
|
|
40
|
+
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
raise Spiffe::Error, 'No X.509 SVID available' unless @x509_svid_cache
|
|
43
|
+
@x509_svid_cache
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get the current X.509 SVID without blocking
|
|
48
|
+
# @return [X509SVIDWrapper, nil]
|
|
49
|
+
def current_x509_svid
|
|
50
|
+
@mutex.synchronize { @x509_svid_cache }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fetch all X.509 SVIDs
|
|
54
|
+
# @return [Array<X509SVIDWrapper>]
|
|
55
|
+
def x509_svids
|
|
56
|
+
ensure_x509_svid_stream_started
|
|
57
|
+
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@x509_svids_cache || []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fetch JWT SVID for the given audience
|
|
64
|
+
# @param audience [String, Array<String>] Target audience(s)
|
|
65
|
+
# @param spiffe_id [String, nil] Optional specific SPIFFE ID to request
|
|
66
|
+
# @return [JWTSVIDWrapper] The JWT SVID
|
|
67
|
+
# @raise [Spiffe::Error] If fetch fails
|
|
68
|
+
def jwt_svid(audience:, spiffe_id: nil)
|
|
69
|
+
audiences = audience.is_a?(Array) ? audience : [audience]
|
|
70
|
+
|
|
71
|
+
request = Spiffe::Workload::JWTSVIDRequest.new(
|
|
72
|
+
audience: audiences,
|
|
73
|
+
spiffe_id: spiffe_id || ''
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
metadata = { 'workload.spiffe.io' => '1' }
|
|
77
|
+
response = stub.fetch_jwtsvid(request, metadata: metadata)
|
|
78
|
+
|
|
79
|
+
raise Spiffe::Error, 'No JWT SVID returned' if response.svids.empty?
|
|
80
|
+
|
|
81
|
+
JWTSVIDWrapper.from_proto(response.svids.first)
|
|
82
|
+
rescue GRPC::BadStatus => e
|
|
83
|
+
raise Spiffe::Error, "Failed to fetch JWT SVID: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fetch X.509 trust bundles
|
|
87
|
+
# @return [Hash<String, OpenSSL::X509::Store>] Map of trust domain to bundle
|
|
88
|
+
def x509_bundles
|
|
89
|
+
ensure_x509_bundles_stream_started
|
|
90
|
+
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@x509_bundles_cache || {}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Fetch JWT bundles
|
|
97
|
+
# @return [Hash<String, String>] Map of trust domain to JWKS
|
|
98
|
+
def jwt_bundles
|
|
99
|
+
ensure_jwt_bundles_stream_started
|
|
100
|
+
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@jwt_bundles_cache || {}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Create an OpenSSL context configured with the current SVID
|
|
107
|
+
# @return [OpenSSL::SSL::SSLContext]
|
|
108
|
+
def tls_context
|
|
109
|
+
svid = x509_svid
|
|
110
|
+
|
|
111
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
112
|
+
context.cert = svid.leaf_certificate
|
|
113
|
+
context.key = svid.private_key
|
|
114
|
+
context.extra_chain_cert = svid.cert_chain[1..-1] if svid.cert_chain.length > 1
|
|
115
|
+
context.cert_store = svid.trust_bundle
|
|
116
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
117
|
+
context
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Register a callback to be called when X.509 SVID is updated
|
|
121
|
+
# @yield [X509SVID] The new SVID
|
|
122
|
+
def on_x509_svid_update(&block)
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
@x509_update_callbacks ||= []
|
|
125
|
+
@x509_update_callbacks << block
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Shutdown the client and stop all background threads
|
|
130
|
+
def shutdown
|
|
131
|
+
@shutdown = true
|
|
132
|
+
@rotation_thread&.kill
|
|
133
|
+
@x509_stream_thread&.kill
|
|
134
|
+
@x509_bundles_stream_thread&.kill
|
|
135
|
+
@jwt_bundles_stream_thread&.kill
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def validate_socket!
|
|
141
|
+
unless File.exist?(@socket_path)
|
|
142
|
+
raise Spiffe::SocketError, "Socket does not exist: #{@socket_path}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless File.socket?(@socket_path)
|
|
146
|
+
raise Spiffe::SocketError, "Path is not a socket: #{@socket_path}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check socket permissions (should be accessible)
|
|
150
|
+
unless File.readable?(@socket_path) && File.writable?(@socket_path)
|
|
151
|
+
raise Spiffe::SocketError, "Socket is not accessible: #{@socket_path}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Interceptor to add SPIRE security header to all requests
|
|
156
|
+
class SpireHeaderInterceptor < GRPC::ClientInterceptor
|
|
157
|
+
def request_response(request:, call:, method:, metadata:)
|
|
158
|
+
metadata['workload.spiffe.io'] = '1'
|
|
159
|
+
yield
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def client_streamer(requests:, call:, method:, metadata:)
|
|
163
|
+
metadata['workload.spiffe.io'] = '1'
|
|
164
|
+
yield
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def server_streamer(request:, call:, method:, metadata:)
|
|
168
|
+
metadata['workload.spiffe.io'] = '1'
|
|
169
|
+
yield
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def bidi_streamer(requests:, call:, method:, metadata:)
|
|
173
|
+
metadata['workload.spiffe.io'] = '1'
|
|
174
|
+
yield
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stub
|
|
179
|
+
@stub ||= begin
|
|
180
|
+
# Create gRPC channel to Unix socket
|
|
181
|
+
# Format: unix:///path/to/socket or unix:/path/to/socket
|
|
182
|
+
socket_uri = "unix://#{@socket_path}"
|
|
183
|
+
|
|
184
|
+
channel = GRPC::Core::Channel.new(
|
|
185
|
+
socket_uri,
|
|
186
|
+
{},
|
|
187
|
+
:this_channel_is_insecure
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
Spiffe::Workload::SpiffeWorkloadAPI::Stub.new(
|
|
191
|
+
nil, # host (unused with channel)
|
|
192
|
+
nil, # creds (unused with channel)
|
|
193
|
+
channel_override: channel,
|
|
194
|
+
timeout: @timeout
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def ensure_x509_svid_stream_started
|
|
200
|
+
return if @x509_stream_thread&.alive?
|
|
201
|
+
|
|
202
|
+
@x509_stream_thread = Thread.new do
|
|
203
|
+
loop do
|
|
204
|
+
begin
|
|
205
|
+
stream_x509_svids
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
warn "X.509 SVID stream error: #{e.message}"
|
|
208
|
+
sleep 5 # Retry after delay
|
|
209
|
+
end
|
|
210
|
+
break if @shutdown
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Wait for first SVID
|
|
215
|
+
deadline = Time.now + @timeout
|
|
216
|
+
until @x509_svid_cache || Time.now > deadline
|
|
217
|
+
sleep 0.1
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
raise Spiffe::Error, 'Timeout waiting for X.509 SVID' unless @x509_svid_cache
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def stream_x509_svids
|
|
224
|
+
request = Spiffe::Workload::X509SVIDRequest.new
|
|
225
|
+
metadata = { 'workload.spiffe.io' => '1' }
|
|
226
|
+
|
|
227
|
+
stub.fetch_x509_svid(request, metadata: metadata).each do |response|
|
|
228
|
+
process_x509_response(response)
|
|
229
|
+
break if @shutdown
|
|
230
|
+
end
|
|
231
|
+
rescue GRPC::BadStatus => e
|
|
232
|
+
raise Spiffe::Error, "Failed to fetch X.509 SVID: #{e.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def process_x509_response(response)
|
|
236
|
+
svids = response.svids.map { |svid_proto| X509SVIDWrapper.from_proto(svid_proto) }
|
|
237
|
+
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
@x509_svids_cache = svids
|
|
240
|
+
@x509_svid_cache = svids.first # Default SVID
|
|
241
|
+
|
|
242
|
+
# Notify callbacks
|
|
243
|
+
@x509_update_callbacks&.each do |callback|
|
|
244
|
+
callback.call(@x509_svid_cache)
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
warn "X.509 SVID update callback error: #{e.message}"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def ensure_x509_bundles_stream_started
|
|
252
|
+
return if @x509_bundles_stream_thread&.alive?
|
|
253
|
+
|
|
254
|
+
@x509_bundles_stream_thread = Thread.new do
|
|
255
|
+
loop do
|
|
256
|
+
begin
|
|
257
|
+
stream_x509_bundles
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
warn "X.509 bundles stream error: #{e.message}"
|
|
260
|
+
sleep 5
|
|
261
|
+
end
|
|
262
|
+
break if @shutdown
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Wait for first bundle
|
|
267
|
+
deadline = Time.now + @timeout
|
|
268
|
+
until @x509_bundles_cache || Time.now > deadline
|
|
269
|
+
sleep 0.1
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def stream_x509_bundles
|
|
274
|
+
request = Spiffe::Workload::X509BundlesRequest.new
|
|
275
|
+
metadata = { 'workload.spiffe.io' => '1' }
|
|
276
|
+
|
|
277
|
+
stub.fetch_x509_bundles(request, metadata: metadata).each do |response|
|
|
278
|
+
process_x509_bundles_response(response)
|
|
279
|
+
break if @shutdown
|
|
280
|
+
end
|
|
281
|
+
rescue GRPC::BadStatus => e
|
|
282
|
+
raise Spiffe::Error, "Failed to fetch X.509 bundles: #{e.message}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def process_x509_bundles_response(response)
|
|
286
|
+
bundles = {}
|
|
287
|
+
|
|
288
|
+
response.bundles.to_h.each do |trust_domain, bundle_data|
|
|
289
|
+
store = OpenSSL::X509::Store.new
|
|
290
|
+
|
|
291
|
+
# Parse all certificates in the bundle
|
|
292
|
+
offset = 0
|
|
293
|
+
while offset < bundle_data.bytesize
|
|
294
|
+
begin
|
|
295
|
+
cert = OpenSSL::X509::Certificate.new(bundle_data[offset..-1])
|
|
296
|
+
store.add_cert(cert)
|
|
297
|
+
offset += cert.to_der.bytesize
|
|
298
|
+
rescue OpenSSL::X509::CertificateError
|
|
299
|
+
break
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
bundles[trust_domain] = store
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
@mutex.synchronize do
|
|
307
|
+
@x509_bundles_cache = bundles
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def ensure_jwt_bundles_stream_started
|
|
312
|
+
return if @jwt_bundles_stream_thread&.alive?
|
|
313
|
+
|
|
314
|
+
@jwt_bundles_stream_thread = Thread.new do
|
|
315
|
+
loop do
|
|
316
|
+
begin
|
|
317
|
+
stream_jwt_bundles
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
warn "JWT bundles stream error: #{e.message}"
|
|
320
|
+
sleep 5
|
|
321
|
+
end
|
|
322
|
+
break if @shutdown
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Wait for first bundle
|
|
327
|
+
deadline = Time.now + @timeout
|
|
328
|
+
until @jwt_bundles_cache || Time.now > deadline
|
|
329
|
+
sleep 0.1
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def stream_jwt_bundles
|
|
334
|
+
request = Spiffe::Workload::JWTBundlesRequest.new
|
|
335
|
+
|
|
336
|
+
stub.fetch_jwt_bundles(request).each do |response|
|
|
337
|
+
@mutex.synchronize do
|
|
338
|
+
# Bundles are already in JWKS format
|
|
339
|
+
@jwt_bundles_cache = response.bundles.to_h
|
|
340
|
+
end
|
|
341
|
+
break if @shutdown
|
|
342
|
+
end
|
|
343
|
+
rescue GRPC::BadStatus => e
|
|
344
|
+
raise Spiffe::Error, "Failed to fetch JWT bundles: #{e.message}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'grpc'
|
|
4
|
+
|
|
5
|
+
module Spiffe
|
|
6
|
+
module Workload
|
|
7
|
+
# gRPC stub for SPIFFE Workload API
|
|
8
|
+
# This is a simplified stub. Run 'rake generate_protos' to generate
|
|
9
|
+
# the full proto definitions from workload.proto
|
|
10
|
+
|
|
11
|
+
class SpiffeWorkloadAPIStub < GRPC::ClientStub
|
|
12
|
+
# Fetch X.509 SVIDs (streaming RPC)
|
|
13
|
+
# @param request [Hash] Empty request
|
|
14
|
+
# @return [Enumerator] Stream of X509SVIDResponse
|
|
15
|
+
def fetch_x509_svid(request, metadata: {})
|
|
16
|
+
request_marshal = lambda { |req| '' } # Empty request
|
|
17
|
+
response_unmarshal = lambda { |bytes| parse_x509_svid_response(bytes) }
|
|
18
|
+
|
|
19
|
+
request_response_call(
|
|
20
|
+
'/SpiffeWorkloadAPI/FetchX509SVID',
|
|
21
|
+
request,
|
|
22
|
+
request_marshal,
|
|
23
|
+
response_unmarshal,
|
|
24
|
+
return_op: true,
|
|
25
|
+
metadata: metadata
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Fetch X.509 bundles (streaming RPC)
|
|
30
|
+
# @param request [Hash] Empty request
|
|
31
|
+
# @return [Enumerator] Stream of X509BundlesResponse
|
|
32
|
+
def fetch_x509_bundles(request, metadata: {})
|
|
33
|
+
request_marshal = lambda { |req| '' }
|
|
34
|
+
response_unmarshal = lambda { |bytes| parse_x509_bundles_response(bytes) }
|
|
35
|
+
|
|
36
|
+
request_response_call(
|
|
37
|
+
'/SpiffeWorkloadAPI/FetchX509Bundles',
|
|
38
|
+
request,
|
|
39
|
+
request_marshal,
|
|
40
|
+
response_unmarshal,
|
|
41
|
+
return_op: true,
|
|
42
|
+
metadata: metadata
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fetch JWT SVID (unary RPC)
|
|
47
|
+
# @param request [Hash] JWTSVIDRequest with audience and optional spiffe_id
|
|
48
|
+
# @return [Hash] JWTSVIDResponse
|
|
49
|
+
def fetch_jwt_svid(request, metadata: {})
|
|
50
|
+
request_marshal = lambda { |req| encode_jwt_svid_request(req) }
|
|
51
|
+
response_unmarshal = lambda { |bytes| parse_jwt_svid_response(bytes) }
|
|
52
|
+
|
|
53
|
+
request_response_call(
|
|
54
|
+
'/SpiffeWorkloadAPI/FetchJWTSVID',
|
|
55
|
+
request,
|
|
56
|
+
request_marshal,
|
|
57
|
+
response_unmarshal,
|
|
58
|
+
return_op: false,
|
|
59
|
+
metadata: metadata
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fetch JWT bundles (streaming RPC)
|
|
64
|
+
# @param request [Hash] Empty request
|
|
65
|
+
# @return [Enumerator] Stream of JWTBundlesResponse
|
|
66
|
+
def fetch_jwt_bundles(request, metadata: {})
|
|
67
|
+
request_marshal = lambda { |req| '' }
|
|
68
|
+
response_unmarshal = lambda { |bytes| parse_jwt_bundles_response(bytes) }
|
|
69
|
+
|
|
70
|
+
request_response_call(
|
|
71
|
+
'/SpiffeWorkloadAPI/FetchJWTBundles',
|
|
72
|
+
request,
|
|
73
|
+
request_marshal,
|
|
74
|
+
response_unmarshal,
|
|
75
|
+
return_op: true,
|
|
76
|
+
metadata: metadata
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Note: These are simplified parsers
|
|
83
|
+
# For production use, replace this file with generated proto stubs
|
|
84
|
+
# by running: rake generate_protos
|
|
85
|
+
|
|
86
|
+
def parse_x509_svid_response(bytes)
|
|
87
|
+
# This is a placeholder that returns a mock structure
|
|
88
|
+
# In reality, this should use google-protobuf to decode
|
|
89
|
+
OpenStruct.new(
|
|
90
|
+
svids: [],
|
|
91
|
+
crl: [],
|
|
92
|
+
federated_bundles: {}
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_x509_bundles_response(bytes)
|
|
97
|
+
OpenStruct.new(
|
|
98
|
+
crl: [],
|
|
99
|
+
bundles: {}
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def encode_jwt_svid_request(request)
|
|
104
|
+
# Placeholder encoding
|
|
105
|
+
# Should use google-protobuf to encode
|
|
106
|
+
''
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_jwt_svid_response(bytes)
|
|
110
|
+
OpenStruct.new(
|
|
111
|
+
svids: []
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_jwt_bundles_response(bytes)
|
|
116
|
+
OpenStruct.new(
|
|
117
|
+
bundles: {}
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'tls_config'
|
|
6
|
+
|
|
7
|
+
module Spiffe
|
|
8
|
+
module Workload
|
|
9
|
+
# HTTP client with automatic SPIFFE authentication
|
|
10
|
+
class HTTPClient
|
|
11
|
+
attr_reader :client
|
|
12
|
+
|
|
13
|
+
# @param client [Spiffe::Workload::Client] The workload API client
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
@tls_context = TLSConfig.create_auto_updating_context(client)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Make an HTTPS request with SPIFFE mTLS
|
|
20
|
+
# @param uri [String, URI] The target URI
|
|
21
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete, etc.)
|
|
22
|
+
# @param body [String, nil] Request body
|
|
23
|
+
# @param headers [Hash] Additional headers
|
|
24
|
+
# @return [Net::HTTPResponse]
|
|
25
|
+
def request(uri, method: :get, body: nil, headers: {})
|
|
26
|
+
uri = URI.parse(uri) if uri.is_a?(String)
|
|
27
|
+
|
|
28
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
29
|
+
http.use_ssl = true
|
|
30
|
+
http.ssl_context = @tls_context
|
|
31
|
+
|
|
32
|
+
request_class = case method
|
|
33
|
+
when :get then Net::HTTP::Get
|
|
34
|
+
when :post then Net::HTTP::Post
|
|
35
|
+
when :put then Net::HTTP::Put
|
|
36
|
+
when :delete then Net::HTTP::Delete
|
|
37
|
+
when :head then Net::HTTP::Head
|
|
38
|
+
when :patch then Net::HTTP::Patch
|
|
39
|
+
else raise ArgumentError, "Unsupported method: #{method}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
request = request_class.new(uri)
|
|
43
|
+
headers.each { |key, value| request[key] = value }
|
|
44
|
+
request.body = body if body
|
|
45
|
+
|
|
46
|
+
http.request(request)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convenience methods for common HTTP verbs
|
|
50
|
+
def get(uri, headers: {})
|
|
51
|
+
request(uri, method: :get, headers: headers)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def post(uri, body:, headers: {})
|
|
55
|
+
request(uri, method: :post, body: body, headers: headers)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def put(uri, body:, headers: {})
|
|
59
|
+
request(uri, method: :put, body: body, headers: headers)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def delete(uri, headers: {})
|
|
63
|
+
request(uri, method: :delete, headers: headers)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module Spiffe
|
|
7
|
+
module Workload
|
|
8
|
+
# Represents a JWT SVID
|
|
9
|
+
class JWTSVIDWrapper
|
|
10
|
+
attr_reader :spiffe_id, :token, :hint, :claims
|
|
11
|
+
|
|
12
|
+
# @param spiffe_id [String] The SPIFFE ID
|
|
13
|
+
# @param token [String] The JWT token
|
|
14
|
+
# @param hint [String, nil] Optional hint
|
|
15
|
+
def initialize(spiffe_id:, token:, hint: nil)
|
|
16
|
+
@spiffe_id = spiffe_id
|
|
17
|
+
@token = token
|
|
18
|
+
@hint = hint
|
|
19
|
+
@claims = parse_claims
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parse JWT claims without validation
|
|
23
|
+
# @return [Hash] The JWT claims
|
|
24
|
+
def parse_claims
|
|
25
|
+
# JWT format: header.payload.signature
|
|
26
|
+
parts = @token.split('.')
|
|
27
|
+
raise Error, 'Invalid JWT format' unless parts.length == 3
|
|
28
|
+
|
|
29
|
+
# Decode payload (base64url)
|
|
30
|
+
payload = parts[1]
|
|
31
|
+
# Add padding if necessary
|
|
32
|
+
payload += '=' * (4 - payload.length % 4) if payload.length % 4 != 0
|
|
33
|
+
|
|
34
|
+
decoded = Base64.urlsafe_decode64(payload)
|
|
35
|
+
JSON.parse(decoded)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise Error, "Failed to parse JWT claims: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get the expiration time
|
|
41
|
+
# @return [Time, nil]
|
|
42
|
+
def expiration
|
|
43
|
+
return nil unless @claims['exp']
|
|
44
|
+
Time.at(@claims['exp'])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if the JWT is expired
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def expired?
|
|
50
|
+
exp = expiration
|
|
51
|
+
return false unless exp
|
|
52
|
+
exp < Time.now
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get audience claims
|
|
56
|
+
# @return [Array<String>]
|
|
57
|
+
def audience
|
|
58
|
+
aud = @claims['aud']
|
|
59
|
+
return [] unless aud
|
|
60
|
+
aud.is_a?(Array) ? aud : [aud]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse JWT SVID from proto response
|
|
64
|
+
# @param proto_jwt [Spiffe::Workload::JWTSVID] Proto message
|
|
65
|
+
# @return [JWTSVID]
|
|
66
|
+
def self.from_proto(proto_jwt)
|
|
67
|
+
new(
|
|
68
|
+
spiffe_id: proto_jwt.spiffe_id,
|
|
69
|
+
token: proto_jwt.svid,
|
|
70
|
+
hint: proto_jwt.hint.empty? ? nil : proto_jwt.hint
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|