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 +4 -4
- data/Gemfile +1 -0
- data/README.md +156 -39
- data/leash-sdk.gemspec +5 -3
- data/lib/leash/auth.rb +216 -70
- data/lib/leash/client.rb +138 -0
- data/lib/leash/env.rb +165 -0
- data/lib/leash/errors.rb +107 -15
- data/lib/leash/integrations/base.rb +46 -0
- data/lib/leash/integrations/calendar.rb +64 -0
- data/lib/leash/integrations/drive.rb +53 -0
- data/lib/leash/integrations/gmail.rb +60 -0
- data/lib/leash/integrations/linear.rb +88 -0
- data/lib/leash/integrations.rb +36 -312
- data/lib/leash/transport.rb +188 -0
- data/lib/leash/types.rb +42 -0
- data/lib/leash/version.rb +1 -1
- data/lib/leash.rb +6 -1
- metadata +32 -14
- data/lib/leash/calendar.rb +0 -73
- data/lib/leash/custom_integration.rb +0 -32
- data/lib/leash/drive.rb +0 -48
- data/lib/leash/gmail.rb +0 -70
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 991269a688009e2da61021438552591bb13fc0e7190bfd829305cdaec602cbc3
|
|
4
|
+
data.tar.gz: 5078e8cdf009cc6143763ccb797e217c475102c823b00eef424ba3d344e41ce3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b7a6a7f0959a9ecfcdaf609f6d4c934bb417d4d453ca34a3ace8b730e18339eeb53807be07d6948c298a75867ceb36c29fa021baa557770ca5fb863c7368d77
|
|
7
|
+
data.tar.gz: 40e7ab7d68eed14ee29481cea25e669b957cb88e977c316b0f7e887c4fac93ebffbfc4c5df9308bc1cce885ff02841abe8329c2877e0128ad1bfc332c6425b91
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -1,79 +1,196 @@
|
|
|
1
1
|
# Leash SDK for Ruby
|
|
2
2
|
|
|
3
|
-
Ruby SDK for Leash
|
|
3
|
+
Server-side Ruby SDK for the [Leash](https://leash.build) platform. One client gives you:
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
58
|
+
### Sinatra
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
require "sinatra"
|
|
62
|
+
require "leash"
|
|
28
63
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
72
|
+
### Plain Rack
|
|
38
73
|
|
|
39
|
-
|
|
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
|
-
##
|
|
84
|
+
## What you get
|
|
42
85
|
|
|
43
|
-
|
|
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
|
-
|
|
88
|
+
```ruby
|
|
89
|
+
user = leash.auth.user # Leash::User or nil — never raises
|
|
90
|
+
leash.auth.authenticated? # Boolean
|
|
91
|
+
```
|
|
53
92
|
|
|
54
|
-
|
|
55
|
-
the `leash-auth` cookie set by the Leash platform.
|
|
93
|
+
### `leash.env`
|
|
56
94
|
|
|
57
95
|
```ruby
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
101
|
+
Per-instance TTL cache: 60 seconds. Pass `fresh: true` to bypass the cache for one read.
|
|
102
|
+
|
|
103
|
+
### `leash.integrations`
|
|
64
104
|
|
|
65
|
-
|
|
105
|
+
Typed providers:
|
|
66
106
|
|
|
67
107
|
```ruby
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
12
|
-
spec.description = "
|
|
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 = ">=
|
|
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
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @api private
|
|
126
|
+
def from_cookie_header(request, name)
|
|
86
127
|
raw = nil
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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"]
|