clowk 0.4.0 → 0.5.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/README.md +46 -3
- data/lib/clowk/authenticable.rb +97 -15
- data/lib/clowk/configuration.rb +19 -0
- data/lib/clowk/engine.rb +7 -0
- data/lib/clowk/http/client.rb +12 -2
- data/lib/clowk/jwks.rb +163 -0
- data/lib/clowk/jwt_verifier.rb +72 -9
- data/lib/clowk/version.rb +1 -1
- data/lib/clowk.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b01072bf359496a3554b9f8d71aa01574ac36e0a3af9d3f8f1b3f70f403d9518
|
|
4
|
+
data.tar.gz: f50d5b860acb97711bebaed47f0ba5ab7a3cd9e22e154db84cf2d89a94e207ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 286be44c6c4062b813f88d52c2d47c4a60210a847485a00ebad88e3c73fd4e08092704173446ba3ada396f07d46583d1d3ed5062909dae559f032cd119365d79
|
|
7
|
+
data.tar.gz: c8cff8c774890c14b70a8e90b16828bbf36184d00979cead290381c314f3e52d10e4d5357ff4841aa635caaf17ae843901b6bb8f449cd4947e22b746e79d8f87
|
data/README.md
CHANGED
|
@@ -69,7 +69,7 @@ end
|
|
|
69
69
|
The callback flow includes:
|
|
70
70
|
|
|
71
71
|
- session-backed `state` validation
|
|
72
|
-
- JWT verification
|
|
72
|
+
- JWT verification against Clowk's published keys (RS256), falling back to your instance `secret_key` for legacy HS256 tokens
|
|
73
73
|
- internal-only redirect sanitization
|
|
74
74
|
- session reset before persisting the authenticated user
|
|
75
75
|
- `httponly` cookie persistence with `SameSite=Lax`
|
|
@@ -103,13 +103,56 @@ Important settings:
|
|
|
103
103
|
|
|
104
104
|
| Setting | Purpose |
|
|
105
105
|
| --- | --- |
|
|
106
|
-
| `secret_key` |
|
|
107
|
-
| `publishable_key` | Preferred for auth URL resolution. The gem resolves the latest instance URL from it before sign in/sign up. |
|
|
106
|
+
| `secret_key` | Verifies legacy HS256 tokens. Not needed to verify RS256 tokens. |
|
|
107
|
+
| `publishable_key` | Preferred for auth URL resolution. The gem resolves the latest instance URL from it before sign in/sign up. Also the default `audience`. |
|
|
108
108
|
| `subdomain_url` | Fallback auth domain when you do not want publishable-key-based resolution. |
|
|
109
|
+
| `jwks_url` | Where to fetch Clowk's public keys. Defaults to `<auth domain>/.well-known/jwks.json`. |
|
|
110
|
+
| `audience` | Expected `aud` claim on RS256 tokens. Defaults to `publishable_key`. Set to `nil` to skip the check. |
|
|
109
111
|
| `prefix_by` | Prefix used to generate helper names. Default: `:clowk`. |
|
|
110
112
|
| `mount_path` | Local mount prefix used by helper path generation. Default: `/clowk`. |
|
|
111
113
|
| `callback_path` | Callback route Clowk redirects back to. Default: `/clowk/oauth/callback`. |
|
|
112
114
|
| `http_logger` | Optional logger used by `Clowk::Http`. |
|
|
115
|
+
| `session_status_cache` | Where API-only apps cache session status. Defaults to `Rails.cache`. |
|
|
116
|
+
|
|
117
|
+
### API-only Rails apps
|
|
118
|
+
|
|
119
|
+
`Clowk::Authenticable` works in `ActionController::API` controllers with no
|
|
120
|
+
extra setup — no session middleware, no cookie middleware:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class ApplicationController < ActionController::API
|
|
124
|
+
include Clowk::Authenticable
|
|
125
|
+
|
|
126
|
+
before_action :authenticate_clowk!
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
What changes in that mode:
|
|
131
|
+
|
|
132
|
+
- **Nothing is written back.** A bearer request produces no `Set-Cookie` and no
|
|
133
|
+
session write. There is no browser to hold a cookie, a mobile client ignores
|
|
134
|
+
it, and writing a session per request defeats the point of being stateless.
|
|
135
|
+
- **Failures are always 401 JSON**, never a redirect — including when the
|
|
136
|
+
request carries no `Accept: application/json`. A 302 to a sign-in page is
|
|
137
|
+
useless to an API caller.
|
|
138
|
+
- **Session status is cached in `session_status_cache`** instead of the Rails
|
|
139
|
+
session, keyed by a digest of the token. Without it, `enforce_active_session`
|
|
140
|
+
would cost a round trip to Clowk on every authenticated request.
|
|
141
|
+
|
|
142
|
+
### Token verification
|
|
143
|
+
|
|
144
|
+
Tokens signed with `RS256` are verified against Clowk's public key set, fetched
|
|
145
|
+
from `jwks_url` and cached in memory. The `kid` header selects the key, and a
|
|
146
|
+
`kid` the cache has not seen triggers a single refetch — that is what makes key
|
|
147
|
+
rotation invisible instead of an outage.
|
|
148
|
+
|
|
149
|
+
Because every consumer trusts the same public key under RS256, the `aud` claim
|
|
150
|
+
is what keeps a token minted for one app from verifying in another. It defaults
|
|
151
|
+
to your `publishable_key`, so the check is on without extra configuration.
|
|
152
|
+
|
|
153
|
+
Tokens signed with `HS256` still verify against `secret_key`, so tokens issued
|
|
154
|
+
before Clowk migrated to RS256 keep working until they expire. Audience is not
|
|
155
|
+
enforced on that path: those tokens predate the claim.
|
|
113
156
|
|
|
114
157
|
Auth URL resolution priority:
|
|
115
158
|
|
data/lib/clowk/authenticable.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/concern"
|
|
4
|
+
require "digest"
|
|
4
5
|
|
|
5
6
|
module Clowk
|
|
6
7
|
module Authenticable
|
|
@@ -107,16 +108,23 @@ module Clowk
|
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
def clowk_sign_out!
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
clowk_session_store&.delete(Clowk.config.session_key)
|
|
112
|
+
clowk_cookie_jar&.delete(Clowk.config.cookie_key)
|
|
112
113
|
|
|
113
114
|
@clowk_current_resource = nil
|
|
114
115
|
end
|
|
115
116
|
|
|
116
117
|
private
|
|
117
118
|
|
|
119
|
+
# An API-only app has no session middleware, so there is nowhere to redirect
|
|
120
|
+
# a browser to and no way to come back. Redirecting there would answer a
|
|
121
|
+
# failed API call with a 302 to a sign-in page the caller cannot use.
|
|
122
|
+
def clowk_api_request?
|
|
123
|
+
clowk_session_store.nil? || request.format.json?
|
|
124
|
+
end
|
|
125
|
+
|
|
118
126
|
def clowk_handle_unauthenticated
|
|
119
|
-
if
|
|
127
|
+
if clowk_api_request?
|
|
120
128
|
render json: {error: "Unauthorized"}, status: :unauthorized
|
|
121
129
|
else
|
|
122
130
|
redirect_to clowk_sign_in_path(return_to: request.fullpath)
|
|
@@ -124,13 +132,38 @@ module Clowk
|
|
|
124
132
|
end
|
|
125
133
|
|
|
126
134
|
def clowk_handle_expired_session(_session_info)
|
|
127
|
-
if
|
|
135
|
+
if clowk_api_request?
|
|
128
136
|
render json: {error: "Session expired or inactive"}, status: :unauthorized
|
|
129
137
|
else
|
|
130
138
|
redirect_to clowk_sign_in_path(return_to: request.fullpath)
|
|
131
139
|
end
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# Probed rather than assumed: accessing `session` without the middleware
|
|
143
|
+
# raises, and `respond_to?` is true either way because the method is
|
|
144
|
+
# delegated to the request.
|
|
145
|
+
def clowk_session_store
|
|
146
|
+
return @clowk_session_store if defined?(@clowk_session_store)
|
|
147
|
+
|
|
148
|
+
@clowk_session_store = begin
|
|
149
|
+
store = session
|
|
150
|
+
store.respond_to?(:[]) ? store : nil
|
|
151
|
+
rescue
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def clowk_cookie_jar
|
|
157
|
+
return @clowk_cookie_jar if defined?(@clowk_cookie_jar)
|
|
158
|
+
|
|
159
|
+
@clowk_cookie_jar = begin
|
|
160
|
+
jar = cookies
|
|
161
|
+
jar.respond_to?(:[]=) ? jar : nil
|
|
162
|
+
rescue
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
134
167
|
def verified_request_payload
|
|
135
168
|
return unless extracted_token
|
|
136
169
|
|
|
@@ -152,7 +185,10 @@ module Clowk
|
|
|
152
185
|
end
|
|
153
186
|
|
|
154
187
|
def stored_session
|
|
155
|
-
|
|
188
|
+
store = clowk_session_store
|
|
189
|
+
return if store.nil?
|
|
190
|
+
|
|
191
|
+
raw_session = store[Clowk.config.session_key]
|
|
156
192
|
return unless raw_session.respond_to?(:to_h)
|
|
157
193
|
|
|
158
194
|
raw_session.to_h
|
|
@@ -163,25 +199,31 @@ module Clowk
|
|
|
163
199
|
payload&.deep_symbolize_keys
|
|
164
200
|
end
|
|
165
201
|
|
|
202
|
+
# No-op for API-only apps. A bearer request must not come back with a
|
|
203
|
+
# Set-Cookie: there is no browser to hold it, the mobile client ignores it,
|
|
204
|
+
# and writing a session per request defeats the point of being stateless.
|
|
166
205
|
def persist_clowk_session(token, payload)
|
|
167
|
-
|
|
206
|
+
store = clowk_session_store
|
|
207
|
+
return if store.nil?
|
|
208
|
+
|
|
209
|
+
store[Clowk.config.session_key] = {
|
|
168
210
|
token:,
|
|
169
211
|
user: payload,
|
|
170
212
|
signed_in_at: Time.now.to_i
|
|
171
213
|
}
|
|
172
214
|
|
|
173
|
-
|
|
215
|
+
clowk_cookie_jar&.[]=(Clowk.config.cookie_key, {
|
|
174
216
|
value: token,
|
|
175
217
|
httponly: true,
|
|
176
218
|
same_site: :lax,
|
|
177
219
|
secure: request.ssl?
|
|
178
|
-
}
|
|
220
|
+
})
|
|
179
221
|
end
|
|
180
222
|
|
|
181
223
|
def resolve_session_status
|
|
182
|
-
cached =
|
|
224
|
+
cached = clowk_read_cached_session_status
|
|
183
225
|
|
|
184
|
-
return cached
|
|
226
|
+
return cached if cached
|
|
185
227
|
|
|
186
228
|
resource = clowk_current_resource
|
|
187
229
|
|
|
@@ -192,16 +234,56 @@ module Clowk
|
|
|
192
234
|
result = client.tokens.verify_with_session(token: current_token)
|
|
193
235
|
status = result&.dig(:session)
|
|
194
236
|
|
|
195
|
-
if status
|
|
196
|
-
|
|
237
|
+
clowk_write_cached_session_status(status) if status
|
|
238
|
+
|
|
239
|
+
status
|
|
240
|
+
rescue Clowk::InvalidTokenError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def clowk_read_cached_session_status
|
|
245
|
+
if clowk_session_store
|
|
246
|
+
cached = stored_session&.dig("session_status") || stored_session&.dig(:session_status)
|
|
247
|
+
|
|
248
|
+
return cached&.deep_symbolize_keys if cached && clowk_session_status_fresh?
|
|
249
|
+
|
|
250
|
+
return nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
store = clowk_status_cache
|
|
254
|
+
return nil unless store
|
|
255
|
+
|
|
256
|
+
store.read(clowk_status_cache_key)&.deep_symbolize_keys
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def clowk_write_cached_session_status(status)
|
|
260
|
+
if clowk_session_store
|
|
261
|
+
clowk_session_store[Clowk.config.session_key] = stored_session.merge(
|
|
197
262
|
"session_status" => status,
|
|
198
263
|
"session_status_checked_at" => Time.now.to_i
|
|
199
264
|
)
|
|
265
|
+
|
|
266
|
+
return
|
|
200
267
|
end
|
|
201
268
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
269
|
+
ttl = Clowk.config.session_status_ttl.to_i
|
|
270
|
+
store = clowk_status_cache
|
|
271
|
+
|
|
272
|
+
return unless store && ttl.positive?
|
|
273
|
+
|
|
274
|
+
store.write(clowk_status_cache_key, status, expires_in: ttl)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Stateless apps have no session to hang the cached status on, so without an
|
|
278
|
+
# external store every authenticated request would pay a round trip to
|
|
279
|
+
# Clowk. Keyed by token digest — the raw token must not end up in a cache
|
|
280
|
+
# key that could be logged.
|
|
281
|
+
def clowk_status_cache
|
|
282
|
+
Clowk.config.session_status_cache
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def clowk_status_cache_key
|
|
286
|
+
"clowk:session_status:#{Digest::SHA256.hexdigest(current_token.to_s)}"
|
|
205
287
|
end
|
|
206
288
|
|
|
207
289
|
# Whether the cached status may still be trusted. Timestamped alongside the
|
data/lib/clowk/configuration.rb
CHANGED
|
@@ -12,7 +12,9 @@ module Clowk
|
|
|
12
12
|
attr_accessor :http_retry_attempts
|
|
13
13
|
attr_accessor :http_retry_interval
|
|
14
14
|
attr_accessor :http_write_timeout
|
|
15
|
+
attr_writer :audience
|
|
15
16
|
attr_accessor :issuer
|
|
17
|
+
attr_accessor :jwks_url
|
|
16
18
|
attr_accessor :mount_path
|
|
17
19
|
attr_accessor :publishable_key
|
|
18
20
|
attr_accessor :prefix_by
|
|
@@ -23,6 +25,7 @@ module Clowk
|
|
|
23
25
|
attr_accessor :enforce_active_session
|
|
24
26
|
attr_accessor :on_session_expired
|
|
25
27
|
attr_accessor :session_status_ttl
|
|
28
|
+
attr_writer :session_status_cache
|
|
26
29
|
|
|
27
30
|
def initialize
|
|
28
31
|
@api_base_url = "https://api.clowk.dev/api/v1"
|
|
@@ -51,6 +54,22 @@ module Clowk
|
|
|
51
54
|
@session_status_ttl = 300
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
# Where API-only apps cache session status, since they have no Rails session
|
|
58
|
+
# to hang it on. Defaults to Rails.cache when Rails is loaded; set to nil to
|
|
59
|
+
# check with Clowk on every authenticated request.
|
|
60
|
+
def session_status_cache
|
|
61
|
+
return @session_status_cache if defined?(@session_status_cache)
|
|
62
|
+
|
|
63
|
+
@session_status_cache = (defined?(::Rails) && ::Rails.respond_to?(:cache)) ? ::Rails.cache : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Defaults to the publishable key, which is what Clowk stamps into `aud`.
|
|
67
|
+
# Consumers already configure that key, so audience checking is on by
|
|
68
|
+
# default rather than something you have to know to switch on.
|
|
69
|
+
def audience
|
|
70
|
+
@audience.nil? ? @publishable_key : @audience
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
def after_sign_in_path
|
|
55
74
|
resolve_path(@after_sign_in_path)
|
|
56
75
|
end
|
data/lib/clowk/engine.rb
CHANGED
|
@@ -9,6 +9,13 @@ module Clowk
|
|
|
9
9
|
include Clowk::Helpers::UrlHelpers
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# API-only apps never load :action_controller_base, so without this hook
|
|
13
|
+
# clowk_sign_in_path — reached from the unauthenticated fallback — raises
|
|
14
|
+
# NoMethodError instead of rendering a 401.
|
|
15
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
16
|
+
include Clowk::Helpers::UrlHelpers
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
ActiveSupport.on_load(:action_view) do
|
|
13
20
|
include Clowk::Helpers::UrlHelpers
|
|
14
21
|
end
|
data/lib/clowk/http/client.rb
CHANGED
|
@@ -137,12 +137,22 @@ module Clowk
|
|
|
137
137
|
|
|
138
138
|
def build_uri(path)
|
|
139
139
|
base_uri = URI(base_url)
|
|
140
|
-
|
|
141
|
-
base_uri.
|
|
140
|
+
request_path, request_query = split_query(path)
|
|
141
|
+
base_uri.path = join_paths(base_uri.path, normalize_path(request_path))
|
|
142
|
+
base_uri.query = request_query
|
|
142
143
|
base_uri.fragment = nil
|
|
143
144
|
base_uri
|
|
144
145
|
end
|
|
145
146
|
|
|
147
|
+
# A request path may arrive with its query already attached (the SDK's
|
|
148
|
+
# `search` builds "resource/search?query=..."). URI#path= rejects a value
|
|
149
|
+
# containing "?", so split the query off and set it as its own component
|
|
150
|
+
# instead of smuggling it through the path.
|
|
151
|
+
def split_query(path)
|
|
152
|
+
path_part, _separator, query_part = path.to_s.partition("?")
|
|
153
|
+
[path_part, query_part.empty? ? nil : query_part]
|
|
154
|
+
end
|
|
155
|
+
|
|
146
156
|
def normalize_path(path)
|
|
147
157
|
path.to_s.start_with?("/") ? path : "/#{path}"
|
|
148
158
|
end
|
data/lib/clowk/jwks.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Clowk
|
|
9
|
+
# Fetches and caches Clowk's public key set.
|
|
10
|
+
#
|
|
11
|
+
# Keys are cached process-wide because verifying a token must not cost an
|
|
12
|
+
# HTTP round trip. A `kid` the cache has never seen forces one refetch — that
|
|
13
|
+
# is what makes key rotation invisible to consumers instead of an outage.
|
|
14
|
+
class Jwks
|
|
15
|
+
CACHE_TTL = 600
|
|
16
|
+
WELL_KNOWN_PATH = "/.well-known/jwks.json"
|
|
17
|
+
|
|
18
|
+
# Rotation should resolve in one refetch. Anything beyond that is a
|
|
19
|
+
# misconfigured issuer or a forged `kid`, and hammering the auth server on
|
|
20
|
+
# every unverifiable token turns a bad token into an outage.
|
|
21
|
+
REFETCH_COOLDOWN = 10
|
|
22
|
+
|
|
23
|
+
@cache_mutex = Mutex.new
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def clear_cache!
|
|
27
|
+
@cache_mutex.synchronize do
|
|
28
|
+
@cache = {}
|
|
29
|
+
@last_refetch_at = {}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def key_for(kid, jwks_url: Clowk.config.jwks_url)
|
|
34
|
+
url = jwks_url || default_url
|
|
35
|
+
|
|
36
|
+
key = lookup(url, kid)
|
|
37
|
+
return key if key
|
|
38
|
+
|
|
39
|
+
# Cold or expired cache: this is the ordinary first fetch, not a
|
|
40
|
+
# rotation, so it must not spend the miss budget. Data is fresh
|
|
41
|
+
# afterwards, so a miss here means the kid is genuinely unknown.
|
|
42
|
+
return fetch_and_lookup(url, kid) unless warm?(url)
|
|
43
|
+
|
|
44
|
+
# Warm cache and an unknown kid: the set may have rotated under us.
|
|
45
|
+
return nil unless claim_refetch_slot!(url)
|
|
46
|
+
|
|
47
|
+
fetch_and_lookup(url, kid)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_url
|
|
51
|
+
base = Clowk.config.subdomain_url || Clowk::Subdomain.resolve_url!
|
|
52
|
+
|
|
53
|
+
"#{base.to_s.chomp("/")}#{WELL_KNOWN_PATH}"
|
|
54
|
+
rescue ConfigurationError
|
|
55
|
+
raise ConfigurationError, "set jwks_url, subdomain_url or publishable_key to verify RS256 tokens"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def fetch_and_lookup(url, kid)
|
|
61
|
+
fetch!(url)
|
|
62
|
+
lookup(url, kid)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def warm?(url)
|
|
66
|
+
@cache_mutex.synchronize do
|
|
67
|
+
entry = cache[url]
|
|
68
|
+
|
|
69
|
+
!entry.nil? && entry[:expires_at] > Time.now
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def lookup(url, kid)
|
|
74
|
+
@cache_mutex.synchronize do
|
|
75
|
+
entry = cache[url]
|
|
76
|
+
|
|
77
|
+
next nil unless entry
|
|
78
|
+
next nil if entry[:expires_at] <= Time.now
|
|
79
|
+
|
|
80
|
+
entry[:keys][kid]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Throttles only the refetches a cache miss triggers, not ordinary TTL
|
|
85
|
+
# refreshes. A rotation is picked up on the very next token; a storm of
|
|
86
|
+
# forged kids costs at most one fetch per cooldown window.
|
|
87
|
+
def claim_refetch_slot!(url)
|
|
88
|
+
@cache_mutex.synchronize do
|
|
89
|
+
last = last_refetch_at[url]
|
|
90
|
+
|
|
91
|
+
next false if last && Time.now - last < REFETCH_COOLDOWN
|
|
92
|
+
|
|
93
|
+
last_refetch_at[url] = Time.now
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch!(url)
|
|
99
|
+
uri = URI.parse(url)
|
|
100
|
+
base = uri.dup
|
|
101
|
+
base.path = ""
|
|
102
|
+
base.query = nil
|
|
103
|
+
base.fragment = nil
|
|
104
|
+
|
|
105
|
+
response = Http.get(base_url: base.to_s, path: uri.path, logger: Clowk.config.http_logger)
|
|
106
|
+
|
|
107
|
+
raise InvalidTokenError, "could not fetch JWKS from #{url}" unless response.success?
|
|
108
|
+
|
|
109
|
+
keys = parse(response.body)
|
|
110
|
+
|
|
111
|
+
@cache_mutex.synchronize do
|
|
112
|
+
cache[url] = {keys: keys, expires_at: Time.now + CACHE_TTL}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
keys
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse(body)
|
|
119
|
+
payload = body.is_a?(Hash) ? body : JSON.parse(body.to_s)
|
|
120
|
+
entries = payload["keys"] || payload[:keys] || []
|
|
121
|
+
|
|
122
|
+
entries.each_with_object({}) do |jwk, acc|
|
|
123
|
+
jwk = jwk.transform_keys(&:to_s)
|
|
124
|
+
next unless jwk["kty"] == "RSA" && jwk["kid"]
|
|
125
|
+
|
|
126
|
+
acc[jwk["kid"]] = to_rsa(jwk)
|
|
127
|
+
end
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise InvalidTokenError, "malformed JWKS document: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Built through DER rather than assigning n/e onto a blank key: OpenSSL 3
|
|
133
|
+
# made key objects immutable, so `set_key` no longer exists.
|
|
134
|
+
def to_rsa(jwk)
|
|
135
|
+
sequence = OpenSSL::ASN1::Sequence([
|
|
136
|
+
OpenSSL::ASN1::Integer(decode_bn(jwk["n"])),
|
|
137
|
+
OpenSSL::ASN1::Integer(decode_bn(jwk["e"]))
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
OpenSSL::PKey::RSA.new(sequence.to_der)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def decode_bn(value)
|
|
144
|
+
OpenSSL::BN.new(Base64.urlsafe_decode64(pad(value.to_s)), 2)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# JWKS values are unpadded base64url; Ruby's decoder wants padding.
|
|
148
|
+
def pad(value)
|
|
149
|
+
remainder = value.length % 4
|
|
150
|
+
|
|
151
|
+
remainder.zero? ? value : value + ("=" * (4 - remainder))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cache
|
|
155
|
+
@cache ||= {}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def last_refetch_at
|
|
159
|
+
@last_refetch_at ||= {}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/clowk/jwt_verifier.rb
CHANGED
|
@@ -4,24 +4,87 @@ require "jwt"
|
|
|
4
4
|
|
|
5
5
|
module Clowk
|
|
6
6
|
class JwtVerifier
|
|
7
|
-
|
|
7
|
+
LEGACY_ALGORITHM = "HS256"
|
|
8
|
+
ASYMMETRIC_ALGORITHM = "RS256"
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# Kept because it is part of the gem's public surface. Prefer
|
|
11
|
+
# LEGACY_ALGORITHM — this no longer describes what the verifier accepts.
|
|
12
|
+
ALGORITHM = LEGACY_ALGORITHM
|
|
13
|
+
|
|
14
|
+
def initialize(secret_key: Clowk.config.secret_key, issuer: Clowk.config.issuer,
|
|
15
|
+
audience: Clowk.config.audience, jwks_url: Clowk.config.jwks_url)
|
|
10
16
|
@secret_key = secret_key
|
|
11
17
|
@issuer = issuer
|
|
18
|
+
@audience = audience
|
|
19
|
+
@jwks_url = jwks_url
|
|
12
20
|
end
|
|
13
21
|
|
|
14
22
|
def verify(token)
|
|
23
|
+
payload, = asymmetric?(token) ? verify_asymmetric(token) : verify_legacy(token)
|
|
24
|
+
payload.deep_symbolize_keys
|
|
25
|
+
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature,
|
|
26
|
+
JWT::InvalidIssuerError, JWT::InvalidAudError => e
|
|
27
|
+
raise InvalidTokenError, e.message
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def asymmetric?(token)
|
|
33
|
+
header(token)["alg"] == ASYMMETRIC_ALGORITHM
|
|
34
|
+
rescue JWT::DecodeError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def header(token)
|
|
39
|
+
JWT.decode(token, nil, false).last
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Tokens signed with the shared instance secret. Kept so tokens issued
|
|
43
|
+
# before the RS256 migration keep working until they expire.
|
|
44
|
+
def verify_legacy(token)
|
|
15
45
|
raise ConfigurationError, "missing Clowk secret_key" if @secret_key.to_s.empty?
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
options[:verify_iss] = @issuer.present?
|
|
47
|
+
JWT.decode(token, @secret_key, true, base_options.merge(algorithm: LEGACY_ALGORITHM))
|
|
48
|
+
end
|
|
20
49
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
raise InvalidTokenError,
|
|
50
|
+
def verify_asymmetric(token)
|
|
51
|
+
kid = header(token)["kid"]
|
|
52
|
+
|
|
53
|
+
raise InvalidTokenError, "token is missing kid header" if kid.to_s.empty?
|
|
54
|
+
|
|
55
|
+
key = Jwks.key_for(kid, jwks_url: @jwks_url)
|
|
56
|
+
|
|
57
|
+
raise InvalidTokenError, "unknown signing key: #{kid}" unless key
|
|
58
|
+
|
|
59
|
+
JWT.decode(token, key, true, asymmetric_options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def base_options
|
|
63
|
+
opts = {}
|
|
64
|
+
|
|
65
|
+
if @issuer
|
|
66
|
+
opts[:iss] = @issuer
|
|
67
|
+
opts[:verify_iss] = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Audience is enforced only here, and deliberately so. Under RS256 every
|
|
74
|
+
# consumer trusts the same public key, so without `aud` a token minted for
|
|
75
|
+
# another app would verify cleanly — the shared secret used to prevent that
|
|
76
|
+
# by accident. HS256 tokens are already scoped by the per-instance secret,
|
|
77
|
+
# and the ones issued before this claim existed carry no `aud` at all, so
|
|
78
|
+
# enforcing it there would reject valid tokens during the migration window.
|
|
79
|
+
def asymmetric_options
|
|
80
|
+
opts = base_options.merge(algorithm: ASYMMETRIC_ALGORITHM)
|
|
81
|
+
|
|
82
|
+
if @audience
|
|
83
|
+
opts[:aud] = @audience
|
|
84
|
+
opts[:verify_aud] = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
opts
|
|
25
88
|
end
|
|
26
89
|
end
|
|
27
90
|
end
|
data/lib/clowk/version.rb
CHANGED
data/lib/clowk.rb
CHANGED
|
@@ -48,6 +48,7 @@ require_relative "clowk/sdk/token"
|
|
|
48
48
|
require_relative "clowk/sdk/session_config"
|
|
49
49
|
require_relative "clowk/sdk/client"
|
|
50
50
|
require_relative "clowk/subdomain"
|
|
51
|
+
require_relative "clowk/jwks"
|
|
51
52
|
require_relative "clowk/jwt_verifier"
|
|
52
53
|
require_relative "clowk/helpers/url_helpers"
|
|
53
54
|
require_relative "clowk/middleware/token_extractor"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clowk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clowk
|
|
@@ -115,6 +115,7 @@ files:
|
|
|
115
115
|
- lib/clowk/http/response.rb
|
|
116
116
|
- lib/clowk/http/retry_middleware.rb
|
|
117
117
|
- lib/clowk/http/timeout_middleware.rb
|
|
118
|
+
- lib/clowk/jwks.rb
|
|
118
119
|
- lib/clowk/jwt_verifier.rb
|
|
119
120
|
- lib/clowk/middleware/token_extractor.rb
|
|
120
121
|
- lib/clowk/sdk/client.rb
|