leash-sdk 0.3.1 → 0.4.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: 3ccdeebdeeb2eea92794836b7ae51f02cc1c048a3912483c8c410e662184c5f4
4
- data.tar.gz: 9626c0261ec8d76cd14323b290c558be5df6707b0c7cdb6a7cc780feaeeee079
3
+ metadata.gz: 991269a688009e2da61021438552591bb13fc0e7190bfd829305cdaec602cbc3
4
+ data.tar.gz: 5078e8cdf009cc6143763ccb797e217c475102c823b00eef424ba3d344e41ce3
5
5
  SHA512:
6
- metadata.gz: 03d22fe090135500591a41e86aadb0fe90409a0b572f43d9671362ce989737e9f238a987d4665e6a32a0d1197c1e9fb21b97039e836cfc2d1d28cea9cf4938a3
7
- data.tar.gz: 5a0c39178994eff5875f52793d772b52cc0f0304d0c17bd63e6f7fce742909b8ff373ec2545a121f30c4762d0712bb5bcbb09e23e7147c43f43e77294af00485
6
+ metadata.gz: 9b7a6a7f0959a9ecfcdaf609f6d4c934bb417d4d453ca34a3ace8b730e18339eeb53807be07d6948c298a75867ceb36c29fa021baa557770ca5fb863c7368d77
7
+ data.tar.gz: 40e7ab7d68eed14ee29481cea25e669b957cb88e977c316b0f7e887c4fac93ebffbfc4c5df9308bc1cce885ff02841abe8329c2877e0128ad1bfc332c6425b91
data/Gemfile CHANGED
@@ -5,3 +5,4 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  gem "minitest", ">= 5.0"
8
+ gem "rake", ">= 13.0"
data/README.md CHANGED
@@ -1,79 +1,196 @@
1
1
  # Leash SDK for Ruby
2
2
 
3
- Ruby SDK for Leash-hosted integrations.
3
+ Server-side Ruby SDK for the [Leash](https://leash.build) platform. One client gives you:
4
4
 
5
- Use it to call Gmail, Google Calendar, Google Drive, and custom provider actions through the Leash platform proxy.
5
+ - the authenticated user off the request
6
+ - runtime env-var resolution from the Leash secret-source registry
7
+ - typed integrations (Gmail, Google Calendar, Google Drive, Linear)
8
+ - a generic escape hatch for any provider on the platform
9
+
10
+ Framework-agnostic: works with **Rails**, **Sinatra**, **Hanami**, or plain **Rack** — no framework gems required.
6
11
 
7
12
  ## Installation
8
13
 
14
+ Add to your `Gemfile`:
15
+
9
16
  ```ruby
10
17
  gem "leash-sdk"
11
18
  ```
12
19
 
13
- or:
20
+ or install directly:
14
21
 
15
22
  ```bash
16
23
  gem install leash-sdk
17
24
  ```
18
25
 
19
- ## Quick Start
26
+ Requires Ruby `>= 2.7` (Ruby `3.0+` recommended — 2.7 is EOL since 2023-03; system macOS Ruby 2.6.x is below the floor and will need a newer Ruby via rbenv/asdf/homebrew). The only runtime dependency is [`jwt`](https://rubygems.org/gems/jwt).
27
+
28
+ ## Setup
29
+
30
+ Set the platform API key in your environment:
31
+
32
+ ```bash
33
+ export LEASH_API_KEY=lsk_live_...
34
+ ```
35
+
36
+ The constructor reads `LEASH_API_KEY` automatically; you can also pass `api_key:` explicitly.
37
+
38
+ ## Usage
39
+
40
+ ### Rails
20
41
 
21
42
  ```ruby
22
- require "leash"
43
+ # app/controllers/inbox_controller.rb
44
+ class InboxController < ApplicationController
45
+ def index
46
+ leash = Leash.new(request: request)
47
+
48
+ if leash.auth.authenticated?
49
+ @user = leash.auth.user # Leash::User or nil
50
+ @messages = leash.integrations.gmail.list_messages(max_results: 10)
51
+ else
52
+ redirect_to "/login"
53
+ end
54
+ end
55
+ end
56
+ ```
23
57
 
24
- client = Leash::Integrations.new(
25
- auth_token: ENV["LEASH_AUTH_TOKEN"],
26
- api_key: ENV["LEASH_API_KEY"]
27
- )
58
+ ### Sinatra
59
+
60
+ ```ruby
61
+ require "sinatra"
62
+ require "leash"
28
63
 
29
- if client.connected?("gmail")
30
- messages = client.gmail.list_messages(max_results: 5)
31
- puts messages
32
- else
33
- puts client.connect_url("gmail", return_url: "https://myapp.example.com/settings")
64
+ get "/me" do
65
+ leash = Leash.new(request: request)
66
+ user = leash.auth.user
67
+ halt 401 unless user
68
+ user.to_h.to_json
34
69
  end
35
70
  ```
36
71
 
37
- ## Default Platform URL
72
+ ### Plain Rack
38
73
 
39
- - `https://leash.build`
74
+ ```ruby
75
+ class MyApp
76
+ def call(env)
77
+ leash = Leash.new(request: env) # Rack `env` hash works directly
78
+ secret = leash.env.get("OPENAI_API_KEY")
79
+ [200, {}, [secret ? "ok" : "missing"]]
80
+ end
81
+ end
82
+ ```
40
83
 
41
- ## Features
84
+ ## What you get
42
85
 
43
- - Gmail
44
- - Google Calendar
45
- - Google Drive
46
- - connection status lookup
47
- - connect URL generation
48
- - generic provider calls
49
- - custom integration calls
50
- - app env fetch and caching
86
+ ### `leash.auth`
51
87
 
52
- ## Server Auth
88
+ ```ruby
89
+ user = leash.auth.user # Leash::User or nil — never raises
90
+ leash.auth.authenticated? # Boolean
91
+ ```
53
92
 
54
- The SDK includes helpers for authenticating users on the server side by reading
55
- the `leash-auth` cookie set by the Leash platform.
93
+ ### `leash.env`
56
94
 
57
95
  ```ruby
58
- # Rails / Sinatra
59
- user = Leash::Auth.get_user(request)
60
- # => #<Leash::User id="usr_123" email="alice@example.com" name="Alice">
96
+ key = leash.env.get("OPENAI_API_KEY") # String or nil (nil = not declared)
97
+ fresh = leash.env.get("STRIPE_KEY", fresh: true)
98
+ many = leash.env.get_many(["A", "B"]) # { "A" => "...", "B" => nil }
61
99
  ```
62
100
 
63
- ## MCP Calls
101
+ Per-instance TTL cache: 60 seconds. Pass `fresh: true` to bypass the cache for one read.
102
+
103
+ ### `leash.integrations`
64
104
 
65
- Execute MCP-backed tools through the platform:
105
+ Typed providers:
66
106
 
67
107
  ```ruby
68
- result = client.run_mcp(package: "@some/mcp-package", tool: "tool-name", args: { key: "value" })
108
+ # Gmail
109
+ leash.integrations.gmail.list_messages(max_results: 5)
110
+ leash.integrations.gmail.get_message("msg-id")
111
+ leash.integrations.gmail.send_message(to: "a@b.com", subject: "Hi", body: "Hello")
112
+ leash.integrations.gmail.search_messages("from:x@y.com")
113
+ leash.integrations.gmail.list_labels
114
+ leash.integrations.gmail.get_profile
115
+
116
+ # Google Calendar (also addressable as leash.integrations.google_calendar)
117
+ leash.integrations.calendar.list_calendars
118
+ leash.integrations.calendar.list_events(time_min: "2026-01-01T00:00:00Z")
119
+ leash.integrations.calendar.create_event(
120
+ summary: "Standup",
121
+ start: { "dateTime" => "2026-05-15T10:00:00Z" },
122
+ end_time: { "dateTime" => "2026-05-15T10:30:00Z" }
123
+ )
124
+ leash.integrations.calendar.get_event("evt-1")
125
+
126
+ # Google Drive (also addressable as leash.integrations.google_drive)
127
+ leash.integrations.drive.list_files(query: "name contains 'q'")
128
+ leash.integrations.drive.get_file("file-id")
129
+ leash.integrations.drive.download_file("file-id")
130
+ leash.integrations.drive.create_folder("Receipts", parent_id: "p-1")
131
+ leash.integrations.drive.upload_file(name: "x.txt", content: "data", mime_type: "text/plain")
132
+ leash.integrations.drive.delete_file("file-id")
133
+ leash.integrations.drive.search_files("invoice")
134
+
135
+ # Linear
136
+ leash.integrations.linear.list_issues(state_type: "started")
137
+ leash.integrations.linear.get_issue("LEA-123")
138
+ leash.integrations.linear.create_issue(team_id: "t-1", title: "Build it")
139
+ leash.integrations.linear.update_issue("LEA-123", priority: 1)
140
+ leash.integrations.linear.add_comment("LEA-123", "ship it")
141
+ leash.integrations.linear.list_teams
142
+ leash.integrations.linear.list_projects
69
143
  ```
70
144
 
71
- ## Notes
145
+ Generic escape hatch for any platform-registered provider (Slack, GitHub, HubSpot, Jira, …):
146
+
147
+ ```ruby
148
+ leash.integrations.provider("slack").call("post_message",
149
+ body: { "channel" => "#general", "text" => "hi" })
150
+ ```
151
+
152
+ ## Errors
153
+
154
+ Every call raises a `Leash::Error` (or one of its subclasses) on failure:
155
+
156
+ | Class | Raised when |
157
+ |----------------------------------|--------------------------------------------|
158
+ | `Leash::UnauthorizedError` | platform returned 401 (missing/invalid creds) |
159
+ | `Leash::ConnectionRequiredError` | 403 (provider not connected for this user) |
160
+ | `Leash::UpgradeRequiredError` | 402 (feature gated behind a higher plan) |
161
+ | `Leash::KeyNotDeclaredError` | manually raised for env mis-declarations |
162
+ | `Leash::NetworkError` | transport-level failures (DNS, refused, timeout) |
163
+ | `Leash::Error` | base class — also raised for generic 4xx/5xx |
164
+
165
+ Every error carries `code`, `message`, `action`, `see_also`, `status`, and (where present) `connect_url`.
166
+
167
+ For convenience, the 0.3 aliases `Leash::NotConnectedError` and `Leash::PlanBlockError` are still available.
72
168
 
73
- - `auth_token` should be a valid Leash platform JWT
74
- - `api_key` is optional, but useful for app-scoped access
75
- - OAuth token handling remains a platform concern
169
+ ## Authentication precedence
170
+
171
+ The constructor inspects the request in this order:
172
+
173
+ 1. **`LEASH_API_KEY` env var** (or explicit `api_key:` constructor arg) — server-only, never request-bound.
174
+ 2. **`Authorization: Bearer <jwt>`** header on the request — used by `auth.user` and as an env-read fallback when no API key is present. **Never** forwarded on integration POSTs.
175
+ 3. **`leash-auth` cookie** on the request — the standard browser → deployed-app session.
176
+
177
+ The Bearer-token → env fallback is intentional so CLI/agent flows can read env-vars with a user JWT when no app key is provisioned. The same JWT is **never** sent on integration POSTs because the platform's `verifyToken()` rejects it before the API-key check runs, producing a misleading 401.
178
+
179
+ ## What's NOT in 0.4 yet
180
+
181
+ - `create_dev_auth_handler` / `attach_local_dev_handler` — there's no Rack-side equivalent shipped in 0.4. (The TS SDK has a `Leash.createDevAuthHandler` for Next.js routes.) If you need a Ruby local-dev cookie-exchange flow, open an issue.
182
+ - Legacy `LeashIntegrations` class — the 0.3 `Leash::Integrations.new(auth_token: ..., api_key: ...)` constructor and its `gmail` / `calendar` / `drive` accessors have been replaced by `Leash.new(request: ...)` + the namespaced `.integrations` accessor. The TS SDK dropped the equivalent class in 0.4 too.
183
+ - Browser-mode usage — server-only in 0.4.
184
+
185
+ If you're upgrading from 0.3.x, the `Leash::Auth.get_user(request)` helper and the `Leash::User` / `Leash::Error` / `Leash::NotConnectedError` / `Leash::TokenExpiredError` classes are kept for backwards compatibility.
186
+
187
+ ## Testing
188
+
189
+ ```bash
190
+ bundle install
191
+ bundle exec rake test
192
+ ```
76
193
 
77
194
  ## License
78
195
 
79
- Apache-2.0
196
+ Apache 2.0.
data/leash-sdk.gemspec CHANGED
@@ -8,11 +8,11 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Leash"]
9
9
  spec.email = ["hello@leash.build"]
10
10
 
11
- spec.summary = "Ruby SDK for the Leash platform integrations API"
12
- spec.description = "Access Gmail, Google Calendar, Google Drive, and more through the Leash platform proxy. No API keys needed -- uses your Leash auth token."
11
+ spec.summary = "Unified Ruby SDK for the Leash platform — auth, env, integrations."
12
+ spec.description = "Server-side Leash client. Resolve the request user, read app env-vars at runtime, and call platform integrations (Gmail, Google Calendar, Google Drive, Linear, plus a generic escape hatch). Framework-agnostic works with Rails, Sinatra, Hanami, or plain Rack."
13
13
  spec.homepage = "https://github.com/leash-build/leash-sdk-ruby"
14
14
  spec.license = "Apache-2.0"
15
- spec.required_ruby_version = ">= 3.0"
15
+ spec.required_ruby_version = ">= 2.7"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = spec.homepage
@@ -22,4 +22,6 @@ Gem::Specification.new do |spec|
22
22
  spec.require_paths = ["lib"]
23
23
 
24
24
  spec.add_dependency "jwt", ">= 2.7"
25
+
26
+ spec.add_development_dependency "minitest", ">= 5.0"
25
27
  end
data/lib/leash/auth.rb CHANGED
@@ -2,68 +2,46 @@
2
2
 
3
3
  require "jwt"
4
4
  require_relative "errors"
5
+ require_relative "types"
5
6
 
6
7
  module Leash
7
- # Raised when authentication fails (missing cookie, invalid/expired token, etc.)
8
- class AuthError < Error
9
- def initialize(message = "Authentication failed")
10
- super(message, code: "auth_error")
11
- end
12
- end
13
-
14
- # Simple value object representing an authenticated Leash user.
15
- class User
16
- attr_reader :id, :email, :name, :picture
17
-
18
- # @param id [String]
19
- # @param email [String]
20
- # @param name [String, nil]
21
- # @param picture [String, nil]
22
- def initialize(id:, email:, name: nil, picture: nil)
23
- @id = id
24
- @email = email
25
- @name = name
26
- @picture = picture
27
- end
28
-
29
- def ==(other)
30
- other.is_a?(User) &&
31
- id == other.id &&
32
- email == other.email &&
33
- name == other.name &&
34
- picture == other.picture
35
- end
36
- end
37
-
38
- # Framework-agnostic server auth helper.
8
+ # Cookie + Bearer-token + JWT extraction across Ruby web frameworks.
9
+ #
10
+ # Mirrors the multi-framework strategy in `leash-sdk-ts/src/server/auth.ts`
11
+ # and `leash-sdk-python/leash/auth.py`. Designed to never raise during
12
+ # extraction — `extract_cookie` / `extract_bearer_token` return `nil` on
13
+ # any unexpected request shape so callers can branch cleanly.
39
14
  #
40
- # Works with any request object that exposes either:
41
- # - request.cookies (Hash) Rack / Rails / Sinatra
42
- # - request.env['HTTP_COOKIE'] or request.get_header('HTTP_COOKIE') — raw Rack env
15
+ # Supported request shapes (0.4):
16
+ # * Rack hash (`{"rack.input" => …, "HTTP_COOKIE" => …}`)
17
+ # * Rails `ActionDispatch::Request` (`request.cookies`, `request.headers`)
18
+ # * Sinatra `Sinatra::Request` (`request.cookies`, `request.env`)
19
+ # * Hanami request (responds to `:get_header`)
20
+ # * Anything quacking with `.cookies` / `.env` / `.headers` / `.get_header`
43
21
  #
44
- # Does NOT require rails, sinatra, or rack.
22
+ # Does NOT require rails, sinatra, rack, or hanami — only stdlib + jwt.
45
23
  module Auth
46
24
  COOKIE_NAME = "leash-auth"
25
+ AUTH_HEADER = "authorization"
47
26
 
48
27
  module_function
49
28
 
50
- # Read the leash-auth JWT from the request, decode it, and return a {Leash::User}.
29
+ # ------------------------------------------------------------------
30
+ # Public helpers (kept stable from 0.3)
31
+ # ------------------------------------------------------------------
32
+
33
+ # Decode the request's leash-auth cookie into a {Leash::User}.
51
34
  #
52
- # @param request [#cookies, #env, #get_header] any Rack-like request object
53
- # @return [Leash::User]
54
- # @raise [Leash::AuthError] when the cookie is missing or the token is invalid/expired
35
+ # @raise [Leash::AuthError] when the cookie is missing / invalid / expired.
55
36
  def get_user(request)
56
- token = extract_token(request)
37
+ token = extract_cookie(request)
57
38
  raise AuthError, "Missing leash-auth cookie" if token.nil? || token.empty?
58
39
 
59
40
  payload = decode_token(token)
60
41
  build_user(payload)
61
42
  end
62
43
 
63
- # Check whether the request carries a valid leash-auth cookie.
64
- #
65
- # @param request [#cookies, #env, #get_header]
66
- # @return [Boolean]
44
+ # True when {get_user} would return a user.
67
45
  def authenticated?(request)
68
46
  get_user(request)
69
47
  true
@@ -71,52 +49,220 @@ module Leash
71
49
  false
72
50
  end
73
51
 
74
- # @api private
52
+ # ------------------------------------------------------------------
53
+ # Extraction primitives
54
+ # ------------------------------------------------------------------
55
+
56
+ # Return the named cookie value off any request shape, or `nil`.
57
+ # Never raises — returns `nil` on unexpected shapes.
58
+ def extract_cookie(request, name = COOKIE_NAME)
59
+ return nil if request.nil?
60
+
61
+ from_cookie_jar(request, name) || from_cookie_header(request, name)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ # Backwards-compat alias for the 0.3 internal method name.
75
67
  def extract_token(request)
76
- # Strategy 1: request.cookies hash (Rack / Rails / Sinatra)
77
- if request.respond_to?(:cookies)
78
- cookies = request.cookies
79
- if cookies.is_a?(Hash)
80
- value = cookies[COOKIE_NAME] || cookies[COOKIE_NAME.to_sym]
81
- return value if value
68
+ extract_cookie(request)
69
+ end
70
+
71
+ # Return the JWT off `Authorization: Bearer …` if present, else `nil`.
72
+ # Never raises.
73
+ def extract_bearer_token(request)
74
+ return nil if request.nil?
75
+
76
+ raw = header_lookup(request, AUTH_HEADER)
77
+ return nil unless raw.is_a?(String)
78
+
79
+ parts = raw.split(/\s+/, 2)
80
+ return nil unless parts.length == 2
81
+
82
+ scheme, token = parts
83
+ return nil unless scheme.downcase == "bearer"
84
+
85
+ stripped = token.strip
86
+ return nil if stripped.empty?
87
+
88
+ stripped
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ # ------------------------------------------------------------------
94
+ # Internals
95
+ # ------------------------------------------------------------------
96
+
97
+ # @api private
98
+ def from_cookie_jar(request, name)
99
+ return nil unless request.respond_to?(:cookies)
100
+
101
+ cookies = request.cookies
102
+ return nil if cookies.nil?
103
+
104
+ if cookies.respond_to?(:[])
105
+ value = nil
106
+ begin
107
+ value = cookies[name]
108
+ rescue StandardError
109
+ value = nil
110
+ end
111
+ if value.nil? && cookies.respond_to?(:fetch)
112
+ begin
113
+ value = cookies.fetch(name.to_sym, nil)
114
+ rescue StandardError
115
+ value = nil
116
+ end
82
117
  end
118
+ normalised = normalise_cookie_value(value)
119
+ return normalised unless normalised.nil?
83
120
  end
84
121
 
85
- # Strategy 2: raw Cookie header from env or get_header
122
+ nil
123
+ end
124
+
125
+ # @api private
126
+ def from_cookie_header(request, name)
86
127
  raw = nil
87
- if request.respond_to?(:env) && request.env.is_a?(Hash)
88
- raw = request.env["HTTP_COOKIE"]
89
- end
90
- if raw.nil? && request.respond_to?(:get_header)
91
- begin
92
- raw = request.get_header("HTTP_COOKIE")
128
+
129
+ if request.respond_to?(:env)
130
+ env = begin
131
+ request.env
93
132
  rescue StandardError
94
133
  nil
95
134
  end
135
+ if env.is_a?(Hash)
136
+ raw = env["HTTP_COOKIE"] || env["rack.cookie"] || env["cookie"]
137
+ end
96
138
  end
97
139
 
98
- parse_cookie_header(raw) if raw
140
+ if raw.nil? && request.is_a?(Hash)
141
+ raw = request["HTTP_COOKIE"] ||
142
+ request["rack.cookie"] ||
143
+ request["cookie"] ||
144
+ request[:cookie]
145
+ end
146
+
147
+ if raw.nil?
148
+ raw = header_lookup(request, "cookie")
149
+ end
150
+
151
+ return nil unless raw.is_a?(String) && !raw.empty?
152
+
153
+ parse_cookie_header(raw, name)
99
154
  end
100
155
 
101
156
  # @api private
102
- def parse_cookie_header(header)
157
+ def parse_cookie_header(header, name = COOKIE_NAME)
103
158
  return nil if header.nil?
104
159
 
105
160
  header.split(";").each do |pair|
106
- key, value = pair.strip.split("=", 2)
107
- return value if key == COOKIE_NAME
161
+ k, v = pair.strip.split("=", 2)
162
+ next unless k == name
163
+
164
+ return v.nil? ? nil : v
108
165
  end
109
166
  nil
110
167
  end
111
168
 
169
+ # @api private
170
+ def normalise_cookie_value(value)
171
+ return nil if value.nil?
172
+ return value if value.is_a?(String) && !value.empty?
173
+ return value.value if value.respond_to?(:value) && value.value.is_a?(String)
174
+
175
+ begin
176
+ candidate = value["value"]
177
+ return candidate if candidate.is_a?(String) && !candidate.empty?
178
+ rescue StandardError
179
+ nil
180
+ end
181
+ nil
182
+ end
183
+
184
+ # @api private
185
+ # Case-insensitive header lookup against any mapping or headers-like object.
186
+ def header_lookup(request, name)
187
+ lname = name.downcase
188
+
189
+ # Direct `headers` accessor (Rails / Rack / Hanami)
190
+ if request.respond_to?(:headers)
191
+ h = begin
192
+ request.headers
193
+ rescue StandardError
194
+ nil
195
+ end
196
+ if h
197
+ # Try common variants
198
+ [name, lname, "HTTP_#{name.upcase.tr('-', '_')}"].each do |key|
199
+ begin
200
+ val = h[key]
201
+ return val if val.is_a?(String) && !val.empty?
202
+ rescue StandardError
203
+ next
204
+ end
205
+ end
206
+ if h.respond_to?(:each)
207
+ begin
208
+ h.each do |k, v|
209
+ return v if k.respond_to?(:downcase) && k.downcase == lname && v.is_a?(String)
210
+ end
211
+ rescue StandardError
212
+ # fall through
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ # `get_header` accessor (Rack::Request / Hanami)
219
+ if request.respond_to?(:get_header)
220
+ begin
221
+ val = request.get_header("HTTP_#{name.upcase.tr('-', '_')}")
222
+ return val if val.is_a?(String) && !val.empty?
223
+ rescue StandardError
224
+ # ignore
225
+ end
226
+ begin
227
+ val = request.get_header(name)
228
+ return val if val.is_a?(String) && !val.empty?
229
+ rescue StandardError
230
+ # ignore
231
+ end
232
+ end
233
+
234
+ # Raw `env` hash (Rack)
235
+ if request.respond_to?(:env)
236
+ env = begin
237
+ request.env
238
+ rescue StandardError
239
+ nil
240
+ end
241
+ if env.is_a?(Hash)
242
+ val = env["HTTP_#{name.upcase.tr('-', '_')}"]
243
+ return val if val.is_a?(String) && !val.empty?
244
+ end
245
+ end
246
+
247
+ # Caller passed a plain Hash of headers / env directly
248
+ if request.is_a?(Hash)
249
+ ["HTTP_#{name.upcase.tr('-', '_')}", name, lname, name.capitalize].each do |key|
250
+ val = request[key]
251
+ return val if val.is_a?(String) && !val.empty?
252
+ end
253
+ end
254
+
255
+ nil
256
+ end
257
+
112
258
  # @api private
113
259
  def decode_token(token)
114
260
  secret = ENV["LEASH_JWT_SECRET"]
115
- if secret && !secret.empty?
116
- decoded = JWT.decode(token, secret, true, algorithms: ["HS256"])
117
- else
118
- decoded = JWT.decode(token, nil, false)
119
- end
261
+ decoded = if secret && !secret.empty?
262
+ JWT.decode(token, secret, true, algorithms: ["HS256"])
263
+ else
264
+ JWT.decode(token, nil, false)
265
+ end
120
266
  decoded.first
121
267
  rescue JWT::ExpiredSignature
122
268
  raise AuthError, "Token has expired"
@@ -126,12 +272,12 @@ module Leash
126
272
 
127
273
  # @api private
128
274
  def build_user(payload)
129
- id = payload["id"] || payload["sub"]
275
+ id = payload["id"] || payload["sub"] || payload["userId"]
130
276
  email = payload["email"]
131
277
  raise AuthError, "Token payload missing required fields (id/sub, email)" unless id && email
132
278
 
133
279
  User.new(
134
- id: id,
280
+ id: id.to_s,
135
281
  email: email,
136
282
  name: payload["name"],
137
283
  picture: payload["picture"]