graphmind 0.6.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.
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "set"
6
+ require_relative "../loop_guard"
7
+
8
+ module Graphmind
9
+ module Integrations
10
+ # Shared helpers for provider integrations, including the LLM-step capture
11
+ # rules of contract C1 (0.6.0) — a port of packages/client/src/llm-capture.ts
12
+ # held to the same conformance fixture (packages/client/test/fixtures/llm.json):
13
+ #
14
+ # make_usage the wire TokenUsage: inputTokens is the TOTAL prompt
15
+ # (cache reads and writes included), inclusive: true,
16
+ # cache/reasoning counts only when reported (never 0-filled)
17
+ # normalize_finish_reason / finish_fields
18
+ # stop|length|tool-calls|content-filter|error|other, plus
19
+ # the provider's own string as rawFinishReason
20
+ # tool_call {id?, name, input, inputText?} — inputText only when the
21
+ # argument text does not parse
22
+ # capture_tools tools: [{name, schemaHash}], each definition sent once
23
+ # per run as toolSchemas (sha256 of canonical JSON, 16 hex),
24
+ # as sanitize_tool_definition leaves it (never a credential)
25
+ # pick_params the allow-listed sampling parameters actually sent
26
+ # record a prompt made JSON-safe in FULL: no message-count or
27
+ # length trim (the session's 512 KB shrink is the bound)
28
+ module Support
29
+ # Kept for the one place a non-Hash provider response is shown as text.
30
+ MAX_CONTENT_CHARS = 2000
31
+
32
+ FINISH_REASON_MAP = {
33
+ "stop" => "stop", "end_turn" => "stop", "stop_sequence" => "stop", "pause_turn" => "other",
34
+ "eos" => "stop", "eos_token" => "stop", "complete" => "stop", "completed" => "stop",
35
+ "finished" => "stop",
36
+ "length" => "length", "max_tokens" => "length", "max_output_tokens" => "length",
37
+ "max_completion_tokens" => "length", "model_context_window_exceeded" => "length",
38
+ "model_length" => "length",
39
+ "tool_calls" => "tool-calls", "tool_call" => "tool-calls", "tool_use" => "tool-calls",
40
+ "function_call" => "tool-calls",
41
+ "content_filter" => "content-filter", "content_filtered" => "content-filter",
42
+ "refusal" => "content-filter", "safety" => "content-filter", "recitation" => "content-filter",
43
+ "blocklist" => "content-filter", "prohibited_content" => "content-filter",
44
+ "spii" => "content-filter", "image_safety" => "content-filter",
45
+ "guardrail_intervened" => "content-filter",
46
+ "error" => "error", "failed" => "error", "malformed_function_call" => "error",
47
+ "other" => "other", "unknown" => "other"
48
+ }.freeze
49
+
50
+ # Same list, same order as the TypeScript client (pinned by the fixture).
51
+ SAMPLING_PARAM_KEYS = %w[
52
+ maxOutputTokens maxTokens temperature topP topK stopSequences presencePenalty
53
+ frequencyPenalty seed toolChoice responseFormat reasoning
54
+ max_tokens max_completion_tokens max_output_tokens top_p top_k stop stop_sequences
55
+ presence_penalty frequency_penalty logit_bias logprobs top_logprobs n tool_choice
56
+ parallel_tool_calls response_format reasoning_effort thinking service_tier truncation
57
+ text verbosity prompt_cache_key max_tool_calls context_management output_config
58
+ cache_control
59
+ ].freeze
60
+
61
+ SCHEMA_HASH_HEX_CHARS = 16
62
+ MAX_SCHEMA_RUNS = 256
63
+ MAX_SCHEMA_HASHES_PER_RUN = 1024
64
+ MAX_RECORD_DEPTH = 64
65
+
66
+ # Keys of a tool definition that hold its SCHEMA: recorded verbatim (a
67
+ # schema's property names are the tool's parameter names).
68
+ TOOL_SCHEMA_KEYS = %w[parameters input_schema inputSchema output_schema outputSchema schema format].freeze
69
+ # A key of a tool definition (outside its schema) that may carry a
70
+ # credential or transport configuration: never recorded.
71
+ TOOL_SECRET_KEY_RE = /authori[sz]ation|header|token|secret|passw(?:or)?d|key|cookie|credential|bearer/i
72
+ TOOL_URL_KEY_RE = /url\z/i
73
+ # A URL's userinfo: after its "//" (a backslash may stand for a slash, a
74
+ # tab or newline may sit between them; whatever precedes them is kept) up
75
+ # to the LAST "@" before the path, where URL parsers end it, so a password
76
+ # holding "@" goes whole. Same pattern as TypeScript.
77
+ URL_USERINFO_RE = %r{\A([^/\\]*[/\\][\t\n\r]*[/\\])[^/]*@}
78
+ MAX_TOOL_DEFINITION_DEPTH = 16
79
+
80
+ # What an owner (the session) was sent lives on the owner itself, so it is
81
+ # exactly as alive as the owner. Not an ObjectSpace::WeakMap: its VALUES
82
+ # are weak too, so any GC wiped it and the schemas were re-sent mid-run.
83
+ SCHEMA_MEMORY_IVAR = :@__graphmind_tool_schemas
84
+ SCHEMA_LOCK = Mutex.new
85
+ private_constant :SCHEMA_MEMORY_IVAR, :SCHEMA_LOCK
86
+
87
+ module_function
88
+
89
+ # Read a parameter that may be keyed by Symbol or String.
90
+ def param(parameters, key)
91
+ return nil unless parameters.respond_to?(:[])
92
+
93
+ value = parameters[key.to_sym]
94
+ value.nil? ? parameters[key.to_s] : value
95
+ end
96
+
97
+ def param_key(parameters, key)
98
+ return key.to_sym unless parameters.is_a?(Hash)
99
+ return key.to_sym if parameters.key?(key.to_sym)
100
+ return key.to_s if parameters.key?(key.to_s)
101
+
102
+ key.to_sym
103
+ end
104
+
105
+ def truncate(text, limit = MAX_CONTENT_CHARS)
106
+ string = text.to_s
107
+ string.length > limit ? "#{string[0, limit]}… (#{string.length} chars)" : string
108
+ end
109
+
110
+ # -- usage ---------------------------------------------------------------
111
+
112
+ # A finite non-negative number rounded like JS Math.round, else nil.
113
+ def token_count(value)
114
+ case value
115
+ when Integer then value >= 0 ? value : nil
116
+ when Float then value.finite? && value >= 0 ? (value + 0.5).floor : nil
117
+ end
118
+ end
119
+
120
+ # The sum of the parts that were reported; nil when none was.
121
+ def sum_reported(*parts)
122
+ present = parts.compact
123
+ present.empty? ? nil : present.sum
124
+ end
125
+
126
+ # The wire TokenUsage, or nil when neither count was reported. The schema
127
+ # requires both counts, so the one a provider left out is 0; the optional
128
+ # counts are never invented.
129
+ def make_usage(input: nil, output: nil, cache_read: nil, cache_write: nil, reasoning: nil)
130
+ input = token_count(input)
131
+ output = token_count(output)
132
+ return nil if input.nil? && output.nil?
133
+
134
+ usage = { "inputTokens" => input || 0, "outputTokens" => output || 0, "inclusive" => true }
135
+ { "cacheReadTokens" => cache_read, "cacheWriteTokens" => cache_write,
136
+ "reasoningTokens" => reasoning }.each do |key, value|
137
+ count = token_count(value)
138
+ usage[key] = count unless count.nil?
139
+ end
140
+ usage
141
+ end
142
+
143
+ # Chat Completions usage: prompt_tokens is inclusive; cache reads in
144
+ # prompt_tokens_details.cached_tokens (prompt_cache_hit_tokens on
145
+ # OpenAI-compatible servers), writes in .cache_write_tokens.
146
+ def openai_chat_usage(usage)
147
+ return nil unless usage.is_a?(Hash)
148
+
149
+ details = param(usage, :prompt_tokens_details)
150
+ cache_read = token_count(param(details, :cached_tokens))
151
+ cache_read = token_count(param(usage, :prompt_cache_hit_tokens)) if cache_read.nil?
152
+ make_usage(
153
+ input: param(usage, :prompt_tokens),
154
+ output: param(usage, :completion_tokens),
155
+ cache_read: cache_read,
156
+ cache_write: param(details, :cache_write_tokens),
157
+ reasoning: param(param(usage, :completion_tokens_details), :reasoning_tokens)
158
+ )
159
+ end
160
+
161
+ # Responses usage: input_tokens is inclusive; details in
162
+ # input_tokens_details / output_tokens_details.
163
+ def openai_responses_usage(usage)
164
+ return nil unless usage.is_a?(Hash)
165
+
166
+ details = param(usage, :input_tokens_details)
167
+ make_usage(
168
+ input: param(usage, :input_tokens),
169
+ output: param(usage, :output_tokens),
170
+ cache_read: param(details, :cached_tokens),
171
+ cache_write: param(details, :cache_write_tokens),
172
+ reasoning: param(param(usage, :output_tokens_details), :reasoning_tokens)
173
+ )
174
+ end
175
+
176
+ # Either OpenAI shape, by its keys.
177
+ def openai_usage(usage)
178
+ return nil unless usage.is_a?(Hash)
179
+
180
+ chat = !param(usage, :prompt_tokens).nil? || !param(usage, :completion_tokens).nil?
181
+ chat ? openai_chat_usage(usage) : openai_responses_usage(usage)
182
+ end
183
+
184
+ # ruby_llm token counts: `input` is the NON-cached count in 2.0
185
+ # (Tokens#input, "standard (non-cached) input tokens") and in 1.x (every
186
+ # provider subtracts or reports the uncached tail), so the total adds the
187
+ # cache reads and writes. 1.x names them cached / cache_creation.
188
+ def ruby_llm_usage(input:, output:, cache_read: nil, cache_write: nil, thinking: nil)
189
+ read = token_count(cache_read)
190
+ write = token_count(cache_write)
191
+ make_usage(
192
+ input: sum_reported(token_count(input), read, write),
193
+ output: output,
194
+ cache_read: read,
195
+ cache_write: write,
196
+ reasoning: thinking
197
+ )
198
+ end
199
+
200
+ # -- finish reasons ------------------------------------------------------
201
+
202
+ # The normalized finish reason, or nil when nothing usable was reported. A
203
+ # Symbol (ruby_llm's :stop, :max_tokens) counts as its name.
204
+ def normalize_finish_reason(raw, has_tool_calls = false)
205
+ raw = raw.name if raw.is_a?(Symbol)
206
+ return nil unless raw.is_a?(String)
207
+
208
+ key = raw.strip.downcase.gsub(/[-\s]+/, "_")
209
+ return nil if key.empty?
210
+
211
+ mapped = FINISH_REASON_MAP.fetch(key, "other")
212
+ mapped == "stop" && has_tool_calls ? "tool-calls" : mapped
213
+ end
214
+
215
+ # A `refused` step (OpenAI: a plain stop carrying a refusal) is
216
+ # content-filter, as Anthropic's `refusal` stop reason is.
217
+ def finish_fields(raw, has_tool_calls, refused = false)
218
+ out = {}
219
+ normalized = normalize_finish_reason(raw, has_tool_calls)
220
+ normalized = "content-filter" if refused && normalized == "stop"
221
+ out["finishReason"] = normalized unless normalized.nil?
222
+ raw = raw.name if raw.is_a?(Symbol)
223
+ out["rawFinishReason"] = String.new(raw) if raw.is_a?(String) && !raw.empty?
224
+ out
225
+ end
226
+
227
+ # -- tool calls ----------------------------------------------------------
228
+
229
+ # One requested call as {id?, name, input, inputText?}, or nil without a
230
+ # name. A String `args` is JSON-parsed (blank -> {}; text that does not
231
+ # parse -> input nil + inputText); anything else is the input as it is.
232
+ def tool_call(id, name, args)
233
+ name = name.name if name.is_a?(Symbol)
234
+ return nil unless name.is_a?(String) && !name.empty?
235
+
236
+ out = {}
237
+ out["id"] = String.new(id) if id.is_a?(String) && !id.empty?
238
+ out["name"] = String.new(name)
239
+ if args.nil?
240
+ out["input"] = {}
241
+ elsif args.is_a?(String)
242
+ if args.strip.empty?
243
+ out["input"] = {}
244
+ else
245
+ begin
246
+ out["input"] = JSON.parse(args)
247
+ rescue JSON::ParserError, EncodingError
248
+ out["input"] = nil
249
+ out["inputText"] = String.new(args)
250
+ end
251
+ end
252
+ else
253
+ out["input"] = record(args)
254
+ end
255
+ out
256
+ end
257
+
258
+ # -- tool definitions ----------------------------------------------------
259
+
260
+ def schema_hash(definition)
261
+ Digest::SHA256.hexdigest(Graphmind::LoopGuard.canonicalize(definition))[0, SCHEMA_HASH_HEX_CHARS]
262
+ end
263
+
264
+ # The name of one request tool definition in any provider shape.
265
+ def tool_def_name(entry)
266
+ return nil unless entry.is_a?(Hash)
267
+
268
+ [param(param(entry, :function), :name), param(param(entry, :custom), :name),
269
+ param(entry, :name), param(entry, :type)].each do |candidate|
270
+ candidate = candidate.name if candidate.is_a?(Symbol)
271
+ return String.new(candidate) if candidate.is_a?(String) && !candidate.empty?
272
+ end
273
+ nil
274
+ end
275
+
276
+ # A (JSON-safe) tool definition as it is hashed and recorded: the schema
277
+ # keys verbatim, every key matching TOOL_SECRET_KEY_RE dropped at any
278
+ # depth outside them (an OpenAI `type: "mcp"` tool's authorization and
279
+ # headers), a `*url` value cut to scheme://host/path. A function tool is
280
+ # unchanged. Held to the fixture's toolDefinitions.
281
+ def sanitize_tool_definition(definition, depth = 0)
282
+ case definition
283
+ when Array
284
+ return [] if depth >= MAX_TOOL_DEFINITION_DEPTH
285
+
286
+ definition.map { |item| sanitize_tool_definition(item, depth + 1) }
287
+ when Hash
288
+ return {} if depth >= MAX_TOOL_DEFINITION_DEPTH
289
+
290
+ definition.each_with_object({}) do |(key, item), out|
291
+ next unless key.is_a?(String)
292
+
293
+ if TOOL_SCHEMA_KEYS.include?(key)
294
+ out[key] = item
295
+ elsif key.match?(TOOL_SECRET_KEY_RE)
296
+ next
297
+ elsif item.is_a?(String) && key.match?(TOOL_URL_KEY_RE)
298
+ out[key] = without_url_secrets(item)
299
+ else
300
+ out[key] = sanitize_tool_definition(item, depth + 1)
301
+ end
302
+ end
303
+ else
304
+ definition
305
+ end
306
+ end
307
+
308
+ # scheme://user:pass@host/path?query#fragment -> scheme://host/path
309
+ def without_url_secrets(url)
310
+ cut = [url.index("?"), url.index("#")].compact.min || url.length
311
+ url[0, cut].sub(URL_USERINFO_RE) { Regexp.last_match(1) }
312
+ end
313
+
314
+ # {"tools" => [...], "toolSchemas" => {...}} for one LLM step, or nil. The
315
+ # block names a definition (default: tool_def_name). The definition is
316
+ # recorded JSON-safe and without credentials (sanitize_tool_definition),
317
+ # and hashed in that form. Never raises.
318
+ def capture_tools(owner, run_key, definitions, &describe)
319
+ return nil unless definitions.is_a?(Array) && !definitions.empty?
320
+
321
+ describe ||= method(:tool_def_name)
322
+ tools = []
323
+ schemas = {}
324
+ SCHEMA_LOCK.synchronize do
325
+ sent = run_memory(owner, run_key.to_s)
326
+ # So the session can hand the hashes back (release_tool_schemas) when
327
+ # the event carrying them does not reach the wire whole.
328
+ schemas.instance_variable_set(:@graphmind_sent, sent)
329
+ definitions.each do |definition|
330
+ name = begin
331
+ describe.call(definition)
332
+ rescue StandardError
333
+ nil
334
+ end
335
+ next if name.nil?
336
+
337
+ plain = sanitize_tool_definition(record(definition))
338
+ digest = schema_hash(plain)
339
+ tools << { "name" => name, "schemaHash" => digest }
340
+ next if sent.include?(digest)
341
+
342
+ sent.clear if sent.size >= MAX_SCHEMA_HASHES_PER_RUN
343
+ sent << digest
344
+ schemas[digest] = plain
345
+ end
346
+ end
347
+ return nil if tools.empty?
348
+
349
+ out = { "tools" => tools }
350
+ out["toolSchemas"] = schemas unless schemas.empty?
351
+ out
352
+ rescue StandardError
353
+ nil
354
+ end
355
+
356
+ # The definitions in `tool_schemas` (a capture_tools result's, as it sits
357
+ # in a node.started input) did NOT reach the wire whole: the payload
358
+ # budget shrank that event (emptying every array inside them, `required:
359
+ # []`) or it was dropped. Forget the run was sent them, so its next step
360
+ # that uses them sends them again, intact. The session calls this. Never
361
+ # raises.
362
+ def release_tool_schemas(tool_schemas)
363
+ return unless tool_schemas.is_a?(Hash) && tool_schemas.instance_variable_defined?(:@graphmind_sent)
364
+
365
+ SCHEMA_LOCK.synchronize do
366
+ sent = tool_schemas.instance_variable_get(:@graphmind_sent)
367
+ tool_schemas.remove_instance_variable(:@graphmind_sent)
368
+ tool_schemas.each_key { |digest| sent.delete(digest) } if sent.is_a?(Set)
369
+ end
370
+ rescue StandardError
371
+ nil
372
+ end
373
+
374
+ def run_memory(owner, run_key)
375
+ runs = owner_memory(owner)
376
+ hashes = runs.delete(run_key)
377
+ if hashes.nil?
378
+ hashes = Set.new
379
+ runs.delete(runs.first[0]) while runs.size >= MAX_SCHEMA_RUNS
380
+ end
381
+ runs[run_key] = hashes
382
+ hashes
383
+ end
384
+
385
+ # A frozen owner cannot carry the memory: it gets a fresh one per step, so
386
+ # its schemas are re-sent (extra bytes, never a missing definition).
387
+ def owner_memory(owner)
388
+ return {} if owner.frozen?
389
+
390
+ runs = owner.instance_variable_get(SCHEMA_MEMORY_IVAR)
391
+ runs.is_a?(Hash) ? runs : owner.instance_variable_set(SCHEMA_MEMORY_IVAR, {})
392
+ end
393
+
394
+ # Forget what a session was sent (tests).
395
+ def reset_tool_schema_memory(owner)
396
+ SCHEMA_LOCK.synchronize { owner.instance_variable_set(SCHEMA_MEMORY_IVAR, {}) unless owner.frozen? }
397
+ end
398
+
399
+ # The allow-listed parameters actually given (String or Symbol keys), JSON-safe.
400
+ def pick_params(source)
401
+ return {} unless source.is_a?(Hash)
402
+
403
+ SAMPLING_PARAM_KEYS.each_with_object({}) do |key, out|
404
+ value = param(source, key)
405
+ out[key] = record(value) unless value.nil? || value.respond_to?(:call)
406
+ end
407
+ end
408
+
409
+ # -- full prompts --------------------------------------------------------
410
+
411
+ # `value` made JSON-safe WITHOUT truncation: every item, every character.
412
+ # Binary Strings become {"type" => "binary", "bytes" => n}, a cycle
413
+ # "[circular]", depth past 64 "…[depth limit]". Never raises.
414
+ def record(value, depth = 0, path = nil)
415
+ case value
416
+ when nil, true, false, Integer then return value
417
+ when Float then return value.finite? ? value : nil
418
+ when Symbol then return value.name
419
+ when String
420
+ return value if value.encoding != Encoding::BINARY || value.ascii_only?
421
+
422
+ utf8 = value.dup.force_encoding(Encoding::UTF_8)
423
+ return utf8 if utf8.valid_encoding?
424
+
425
+ return { "type" => "binary", "bytes" => value.bytesize }
426
+ end
427
+ return "…[depth limit]" if depth >= MAX_RECORD_DEPTH
428
+
429
+ path ||= {}.compare_by_identity
430
+ return "[circular]" if path.key?(value)
431
+
432
+ path[value] = true
433
+ begin
434
+ record_container(value, depth, path)
435
+ ensure
436
+ path.delete(value)
437
+ end
438
+ rescue StandardError, SystemStackError
439
+ begin
440
+ value.to_s
441
+ rescue StandardError
442
+ value.class.name.to_s
443
+ end
444
+ end
445
+
446
+ def record_container(value, depth, path)
447
+ case value
448
+ when Hash
449
+ value.each_with_object({}) do |(key, item), out|
450
+ out[key.is_a?(Symbol) ? key.name : key.to_s] = record(item, depth + 1, path)
451
+ end
452
+ when Array, Set then value.map { |item| record(item, depth + 1, path) }
453
+ else
454
+ value.respond_to?(:to_h) ? record(value.to_h, depth + 1, path) : value.to_s
455
+ end
456
+ end
457
+ end
458
+ end
459
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module Graphmind
10
+ # GraphMind Jack — account linking for your API (Ruby port of @graphmind-ai/jack-verify).
11
+ #
12
+ # Jack's `link` sign-in mode is for apps with a session login and no OAuth
13
+ # server. You add two things, both with this module:
14
+ #
15
+ # 1. A link route. Jack sends a signed-in user to it with `?request=<token>`.
16
+ # Verify the request, take the user from your session, redirect to
17
+ # `link_response_url`:
18
+ #
19
+ # jack = Graphmind::Jack::Verifier.new(secret: ENV.fetch("JACK_LINK_SECRET"), issuer: ..., audience: ...)
20
+ #
21
+ # get "/jack/link" do
22
+ # jack.verify_link_request(params[:request])
23
+ # redirect to("/login?next=#{CGI.escape(request.fullpath)}") unless current_user
24
+ # redirect jack.link_response_url(params[:request], user_id: current_user.id, name: current_user.name)
25
+ # end
26
+ #
27
+ # 2. The assertion. Every request Jack makes carries a `Jack-Assertion`
28
+ # header: a 60-second, single-use signed token naming the user and the
29
+ # permissions they granted. `Graphmind::Jack::Middleware` (Rack) puts the
30
+ # verified assertion in `env["jack.user"]`; no header means "not a Jack call".
31
+ #
32
+ # Standard library only. One shared secret (`JACK_LINK_SECRET`, also stored
33
+ # encrypted in Jack) signs all three token kinds (HS256), each with its own `typ`.
34
+ module Jack
35
+ ASSERTION_HEADER = "jack-assertion"
36
+ TYP_REQUEST = "jack-link-request"
37
+ TYP_RESPONSE = "jack-link-response"
38
+ TYP_ASSERTION = "jack-link-assertion"
39
+ RESPONSE_TTL_SECONDS = 300
40
+
41
+ class LinkError < StandardError; end
42
+
43
+ LinkRequest = Struct.new(:request_id, :return_url, :scopes, keyword_init: true)
44
+ Assertion = Struct.new(:user_id, :scopes, :grant_id, :jti, keyword_init: true)
45
+
46
+ module_function
47
+
48
+ def b64url(bytes)
49
+ Base64.urlsafe_encode64(bytes, padding: false)
50
+ end
51
+
52
+ def unb64url(text)
53
+ Base64.urlsafe_decode64(text + ("=" * ((4 - text.length % 4) % 4)))
54
+ end
55
+
56
+ # Compact HS256 JWT. Standard, so Jack (Node) verifies it unchanged.
57
+ def sign_hs256(payload, secret)
58
+ head = b64url(JSON.generate({ alg: "HS256", typ: "JWT" }))
59
+ body = b64url(JSON.generate(payload))
60
+ sig = OpenSSL::HMAC.digest("SHA256", secret, "#{head}.#{body}")
61
+ "#{head}.#{body}.#{b64url(sig)}"
62
+ end
63
+
64
+ # Verify signature (constant-time), alg, typ, iss, aud, exp/nbf/iat.
65
+ def verify_hs256(token, secret, typ:, issuer:, audience:, now: nil, skew: 60)
66
+ parts = token.to_s.split(".", -1)
67
+ raise LinkError, "malformed token" unless parts.length == 3
68
+
69
+ head, body, sig = parts
70
+ begin
71
+ header = JSON.parse(unb64url(head))
72
+ payload = JSON.parse(unb64url(body))
73
+ rescue StandardError
74
+ raise LinkError, "malformed token"
75
+ end
76
+ raise LinkError, "unexpected algorithm" unless header.is_a?(Hash) && header["alg"] == "HS256"
77
+
78
+ expected = OpenSSL::HMAC.digest("SHA256", secret, "#{head}.#{body}")
79
+ given = begin
80
+ unb64url(sig)
81
+ rescue StandardError
82
+ raise LinkError, "bad signature"
83
+ end
84
+ raise LinkError, "bad signature" unless given.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(given, expected)
85
+ raise LinkError, "malformed token" unless payload.is_a?(Hash)
86
+ raise LinkError, "wrong token type (expected #{typ})" unless payload["typ"] == typ
87
+ raise LinkError, "wrong issuer" unless payload["iss"] == issuer
88
+
89
+ auds = payload["aud"].is_a?(Array) ? payload["aud"] : [payload["aud"]]
90
+ raise LinkError, "wrong audience" unless auds.include?(audience)
91
+
92
+ t = (now || Time.now.to_f).to_i
93
+ exp = payload["exp"]
94
+ raise LinkError, "expired" unless exp.is_a?(Numeric) && exp + skew >= t
95
+ raise LinkError, "not yet valid" if payload["nbf"].is_a?(Numeric) && payload["nbf"] - skew > t
96
+ raise LinkError, "issued in the future" if payload["iat"].is_a?(Numeric) && payload["iat"] - skew > t
97
+
98
+ payload
99
+ end
100
+
101
+ # Verify Jack's link requests and assertions; sign link responses.
102
+ class Verifier
103
+ def initialize(secret:, issuer:, audience:, now: nil)
104
+ raise LinkError, "secret must be at least 32 characters" unless secret.is_a?(String) && secret.length >= 32
105
+
106
+ @secret = secret
107
+ @issuer = issuer.sub(%r{/+\z}, "")
108
+ a = URI(audience)
109
+ @audience = "#{a.scheme}://#{a.host}#{a.port && a.port != a.default_port ? ":#{a.port}" : ""}"
110
+ @now = now || -> { Time.now.to_f }
111
+ @seen = {}
112
+ end
113
+
114
+ attr_reader :issuer, :audience
115
+
116
+ def verify_link_request(token)
117
+ p = Jack.verify_hs256(token, @secret, typ: TYP_REQUEST, issuer: @issuer, audience: @audience, now: @now.call)
118
+ request_id = text(p["jti"])
119
+ return_url = text(p["return"])
120
+ raise LinkError, "malformed link request" unless request_id && return_url
121
+ raise LinkError, "link request return URL is not the issuer" unless return_url.start_with?("#{@issuer}/")
122
+
123
+ LinkRequest.new(request_id: request_id, return_url: return_url, scopes: p["scope"].is_a?(String) ? p["scope"].split : [])
124
+ end
125
+
126
+ # The signed response token for a linked user. Usually you want `link_response_url`.
127
+ def link_response(request_token, user_id:, name: nil, email: nil)
128
+ req = verify_link_request(request_token)
129
+ raise LinkError, "user_id is required" unless user_id.is_a?(String) && !user_id.empty?
130
+
131
+ iat = @now.call.to_i
132
+ payload = { typ: TYP_RESPONSE, iss: @audience, aud: @issuer, jti: req.request_id, sub: user_id }
133
+ payload[:name] = name if name && !name.empty?
134
+ payload[:email] = email if email && !email.empty?
135
+ payload[:iat] = iat
136
+ payload[:exp] = iat + RESPONSE_TTL_SECONDS
137
+ Jack.sign_hs256(payload, @secret)
138
+ end
139
+
140
+ # Where to redirect the user after linking.
141
+ def link_response_url(request_token, user_id:, name: nil, email: nil)
142
+ req = verify_link_request(request_token)
143
+ token = link_response(request_token, user_id: user_id, name: name, email: email)
144
+ u = URI(req.return_url)
145
+ query = URI.decode_www_form(u.query || "") << ["response", token]
146
+ u.query = URI.encode_www_form(query)
147
+ u.to_s
148
+ end
149
+
150
+ # Raises LinkError on anything wrong, including a replay.
151
+ def verify_assertion(token)
152
+ p = Jack.verify_hs256(token, @secret, typ: TYP_ASSERTION, issuer: @issuer, audience: @audience, now: @now.call, skew: 30)
153
+ jti = text(p["jti"])
154
+ user_id = text(p["sub"])
155
+ grant_id = text(p["grant_id"])
156
+ raise LinkError, "malformed assertion" unless jti && user_id && grant_id
157
+
158
+ t = @now.call.to_i
159
+ @seen.delete_if { |_, exp| exp < t } if @seen.size >= 10_000
160
+ raise LinkError, "assertion replayed" if (@seen[jti] || 0) >= t
161
+
162
+ @seen[jti] = p["exp"].to_f + 30
163
+ Assertion.new(user_id: user_id, scopes: p["scope"].is_a?(String) ? p["scope"].split : [], grant_id: grant_id, jti: jti)
164
+ end
165
+
166
+ # From a Rack env or a plain header hash. nil when absent.
167
+ def assertion_from(headers)
168
+ value = headers["HTTP_JACK_ASSERTION"] || headers[ASSERTION_HEADER] || headers["Jack-Assertion"]
169
+ return nil if value.nil? || value.to_s.empty?
170
+
171
+ verify_assertion(value.to_s)
172
+ end
173
+
174
+ private
175
+
176
+ def text(value)
177
+ value.is_a?(String) && !value.empty? ? value : nil
178
+ end
179
+ end
180
+
181
+ # Rack middleware: `env["jack.user"]` is the Assertion when a valid header is present;
182
+ # an invalid one is a 401; no header passes through.
183
+ class Middleware
184
+ def initialize(app, verifier)
185
+ @app = app
186
+ @verifier = verifier
187
+ end
188
+
189
+ def call(env)
190
+ begin
191
+ assertion = @verifier.assertion_from(env)
192
+ env["jack.user"] = assertion if assertion
193
+ rescue LinkError => e
194
+ body = JSON.generate({ title: "Unauthorized", status: 401, detail: "invalid Jack assertion: #{e.message}" })
195
+ return [401, { "content-type" => "application/problem+json" }, [body]]
196
+ end
197
+ @app.call(env)
198
+ end
199
+ end
200
+ end
201
+ end