clowk 0.4.1 → 0.5.1
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 +110 -15
- data/lib/clowk/configuration.rb +19 -0
- data/lib/clowk/engine.rb +7 -0
- 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: 5027a092ba122ab807f5c0340bc30399f7ab85f9ef41bc99c5610b68df6f47f7
|
|
4
|
+
data.tar.gz: 1f0dd44bf7696a1e3cffed45cb4c48edc92db3025007db39c6ea721f119619a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20a10b0715b1695694c51b915490ad852ba43004e9f1ec1b0fbc7af1d192737250234d4342ea1cac3db7ef6a94dc992b17795ee5ab2f0e0fcc6b23e7237a7c27
|
|
7
|
+
data.tar.gz: 124f14e7d6e53e7c904faa98befc85d18e7f1644f02d654a01c037555d589f4085069945435c7c81aaad36db4158b019b8f61faaeb359ac61839b09edf65d7a0
|
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,30 @@ 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
|
+
# ActionController::API is the reliable signal, and it has to be checked
|
|
120
|
+
# directly. Probing `session` does not work: with no session middleware
|
|
121
|
+
# loaded, `request.session` still returns a Session object that responds to
|
|
122
|
+
# `[]` and reads as nil — writes vanish, but nothing raises. Inferring
|
|
123
|
+
# "there is a session, so this is a browser" from that answered every API
|
|
124
|
+
# call with a 302 to a sign-in page the caller cannot follow.
|
|
125
|
+
def clowk_api_only?
|
|
126
|
+
defined?(ActionController::API) && is_a?(ActionController::API)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def clowk_api_request?
|
|
130
|
+
clowk_api_only? || clowk_session_store.nil? || request.format.json?
|
|
131
|
+
end
|
|
132
|
+
|
|
118
133
|
def clowk_handle_unauthenticated
|
|
119
|
-
if
|
|
134
|
+
if clowk_api_request?
|
|
120
135
|
render json: {error: "Unauthorized"}, status: :unauthorized
|
|
121
136
|
else
|
|
122
137
|
redirect_to clowk_sign_in_path(return_to: request.fullpath)
|
|
@@ -124,13 +139,44 @@ module Clowk
|
|
|
124
139
|
end
|
|
125
140
|
|
|
126
141
|
def clowk_handle_expired_session(_session_info)
|
|
127
|
-
if
|
|
142
|
+
if clowk_api_request?
|
|
128
143
|
render json: {error: "Session expired or inactive"}, status: :unauthorized
|
|
129
144
|
else
|
|
130
145
|
redirect_to clowk_sign_in_path(return_to: request.fullpath)
|
|
131
146
|
end
|
|
132
147
|
end
|
|
133
148
|
|
|
149
|
+
# nil in API-only controllers. Not because `session` raises there — it does
|
|
150
|
+
# not, see clowk_api_only? — but because whatever it hands back writes to
|
|
151
|
+
# nowhere, and callers branch on this to decide whether persisting is worth
|
|
152
|
+
# doing at all.
|
|
153
|
+
def clowk_session_store
|
|
154
|
+
return @clowk_session_store if defined?(@clowk_session_store)
|
|
155
|
+
|
|
156
|
+
@clowk_session_store =
|
|
157
|
+
if clowk_api_only?
|
|
158
|
+
nil
|
|
159
|
+
else
|
|
160
|
+
begin
|
|
161
|
+
store = session
|
|
162
|
+
store.respond_to?(:[]) ? store : nil
|
|
163
|
+
rescue
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def clowk_cookie_jar
|
|
170
|
+
return @clowk_cookie_jar if defined?(@clowk_cookie_jar)
|
|
171
|
+
|
|
172
|
+
@clowk_cookie_jar = begin
|
|
173
|
+
jar = cookies
|
|
174
|
+
jar.respond_to?(:[]=) ? jar : nil
|
|
175
|
+
rescue
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
134
180
|
def verified_request_payload
|
|
135
181
|
return unless extracted_token
|
|
136
182
|
|
|
@@ -152,7 +198,10 @@ module Clowk
|
|
|
152
198
|
end
|
|
153
199
|
|
|
154
200
|
def stored_session
|
|
155
|
-
|
|
201
|
+
store = clowk_session_store
|
|
202
|
+
return if store.nil?
|
|
203
|
+
|
|
204
|
+
raw_session = store[Clowk.config.session_key]
|
|
156
205
|
return unless raw_session.respond_to?(:to_h)
|
|
157
206
|
|
|
158
207
|
raw_session.to_h
|
|
@@ -163,25 +212,31 @@ module Clowk
|
|
|
163
212
|
payload&.deep_symbolize_keys
|
|
164
213
|
end
|
|
165
214
|
|
|
215
|
+
# No-op for API-only apps. A bearer request must not come back with a
|
|
216
|
+
# Set-Cookie: there is no browser to hold it, the mobile client ignores it,
|
|
217
|
+
# and writing a session per request defeats the point of being stateless.
|
|
166
218
|
def persist_clowk_session(token, payload)
|
|
167
|
-
|
|
219
|
+
store = clowk_session_store
|
|
220
|
+
return if store.nil?
|
|
221
|
+
|
|
222
|
+
store[Clowk.config.session_key] = {
|
|
168
223
|
token:,
|
|
169
224
|
user: payload,
|
|
170
225
|
signed_in_at: Time.now.to_i
|
|
171
226
|
}
|
|
172
227
|
|
|
173
|
-
|
|
228
|
+
clowk_cookie_jar&.[]=(Clowk.config.cookie_key, {
|
|
174
229
|
value: token,
|
|
175
230
|
httponly: true,
|
|
176
231
|
same_site: :lax,
|
|
177
232
|
secure: request.ssl?
|
|
178
|
-
}
|
|
233
|
+
})
|
|
179
234
|
end
|
|
180
235
|
|
|
181
236
|
def resolve_session_status
|
|
182
|
-
cached =
|
|
237
|
+
cached = clowk_read_cached_session_status
|
|
183
238
|
|
|
184
|
-
return cached
|
|
239
|
+
return cached if cached
|
|
185
240
|
|
|
186
241
|
resource = clowk_current_resource
|
|
187
242
|
|
|
@@ -192,16 +247,56 @@ module Clowk
|
|
|
192
247
|
result = client.tokens.verify_with_session(token: current_token)
|
|
193
248
|
status = result&.dig(:session)
|
|
194
249
|
|
|
195
|
-
if status
|
|
196
|
-
|
|
250
|
+
clowk_write_cached_session_status(status) if status
|
|
251
|
+
|
|
252
|
+
status
|
|
253
|
+
rescue Clowk::InvalidTokenError
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def clowk_read_cached_session_status
|
|
258
|
+
if clowk_session_store
|
|
259
|
+
cached = stored_session&.dig("session_status") || stored_session&.dig(:session_status)
|
|
260
|
+
|
|
261
|
+
return cached&.deep_symbolize_keys if cached && clowk_session_status_fresh?
|
|
262
|
+
|
|
263
|
+
return nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
store = clowk_status_cache
|
|
267
|
+
return nil unless store
|
|
268
|
+
|
|
269
|
+
store.read(clowk_status_cache_key)&.deep_symbolize_keys
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def clowk_write_cached_session_status(status)
|
|
273
|
+
if clowk_session_store
|
|
274
|
+
clowk_session_store[Clowk.config.session_key] = stored_session.merge(
|
|
197
275
|
"session_status" => status,
|
|
198
276
|
"session_status_checked_at" => Time.now.to_i
|
|
199
277
|
)
|
|
278
|
+
|
|
279
|
+
return
|
|
200
280
|
end
|
|
201
281
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
282
|
+
ttl = Clowk.config.session_status_ttl.to_i
|
|
283
|
+
store = clowk_status_cache
|
|
284
|
+
|
|
285
|
+
return unless store && ttl.positive?
|
|
286
|
+
|
|
287
|
+
store.write(clowk_status_cache_key, status, expires_in: ttl)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Stateless apps have no session to hang the cached status on, so without an
|
|
291
|
+
# external store every authenticated request would pay a round trip to
|
|
292
|
+
# Clowk. Keyed by token digest — the raw token must not end up in a cache
|
|
293
|
+
# key that could be logged.
|
|
294
|
+
def clowk_status_cache
|
|
295
|
+
Clowk.config.session_status_cache
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def clowk_status_cache_key
|
|
299
|
+
"clowk:session_status:#{Digest::SHA256.hexdigest(current_token.to_s)}"
|
|
205
300
|
end
|
|
206
301
|
|
|
207
302
|
# 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/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.1
|
|
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
|