clowk 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5027a092ba122ab807f5c0340bc30399f7ab85f9ef41bc99c5610b68df6f47f7
4
- data.tar.gz: 1f0dd44bf7696a1e3cffed45cb4c48edc92db3025007db39c6ea721f119619a8
3
+ metadata.gz: 1593747e0040a0056565a40b2bf1699d8242af1d253751ce3ea5c86949ff1f7a
4
+ data.tar.gz: 33147a1e6f88c7970d52bf33403566c09309315c53b8e080416aaece4f1ecc2a
5
5
  SHA512:
6
- metadata.gz: 20a10b0715b1695694c51b915490ad852ba43004e9f1ec1b0fbc7af1d192737250234d4342ea1cac3db7ef6a94dc992b17795ee5ab2f0e0fcc6b23e7237a7c27
7
- data.tar.gz: 124f14e7d6e53e7c904faa98befc85d18e7f1644f02d654a01c037555d589f4085069945435c7c81aaad36db4158b019b8f61faaeb359ac61839b09edf65d7a0
6
+ metadata.gz: 0cf89e539def821193dd451c8902e581423f103acd8881ce67d7eadb9cfd9e2e69302a265769679eb5a906593a89253f4781cf625546ee3bbd3d8d012d15584a
7
+ data.tar.gz: f4993722c5349f80a7a10f39cff94d3b76b4909b700272f23458bca7d227fa263258c78b01cf511e2cb5d30640dae84a991fd686939fc4fbfcd2cee7b2e5e757
data/README.md CHANGED
@@ -107,13 +107,101 @@ Important settings:
107
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
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. |
110
+ | `audience` | Expected `aud` claim on RS256 tokens. Defaults to `publishable_key`. Set to `false` to skip the check. |
111
111
  | `prefix_by` | Prefix used to generate helper names. Default: `:clowk`. |
112
112
  | `mount_path` | Local mount prefix used by helper path generation. Default: `/clowk`. |
113
113
  | `callback_path` | Callback route Clowk redirects back to. Default: `/clowk/oauth/callback`. |
114
114
  | `http_logger` | Optional logger used by `Clowk::Http`. |
115
115
  | `session_status_cache` | Where API-only apps cache session status. Defaults to `Rails.cache`. |
116
116
 
117
+ ### Runtime credentials
118
+
119
+ `Clowk.configure` is the right place for credentials that are a boot constant.
120
+ Some apps do not have that: an operator pastes a publishable key into a settings
121
+ screen and expects sign-in to work on the next request, or one process serves
122
+ several tenants and each one has its own instance.
123
+
124
+ For those, there is one method — `Clowk.with_credentials`. It scopes the
125
+ credentials to a block instead of the process:
126
+
127
+ ```ruby
128
+ Clowk.with_credentials(publishable_key: "pk_live_…") do
129
+ # sign-in URLs, JWKS, token verification and the SDK client
130
+ # all resolve against that instance in here
131
+ end
132
+ ```
133
+
134
+ `secret_key` and `subdomain_url` go in the same way and are both optional —
135
+ RS256 needs no secret, and the auth domain is resolved from the publishable key
136
+ when absent.
137
+
138
+ In a Rails controller, wire it with an `around_action` you name yourself:
139
+
140
+ ```ruby
141
+ class ApplicationController < ActionController::Base
142
+ include Clowk::Authenticable
143
+
144
+ around_action :require_tenant_key!
145
+ before_action :authenticate_clowk_user!
146
+
147
+ private
148
+
149
+ def require_tenant_key!(&)
150
+ Clowk.with_credentials(publishable_key: Tenant.current.key, &)
151
+ end
152
+ end
153
+ ```
154
+
155
+ How you spell the block is yours too — `(&)`, `(&block)`, `(&action)`, or
156
+ `{ yield }`. The examples here use `(&)`; nothing in the gem depends on it.
157
+
158
+ `:allow_own_credentials!`, `:use_workspace_sso!` — the gem does not care, and
159
+ never looks for a method named its way. Where the credentials come from is
160
+ equally yours: an environment variable, a settings row, the subdomain, a header.
161
+
162
+ `nil` runs the block against the boot configuration, so a request with no tenant
163
+ needs no branch at the call site:
164
+
165
+ ```ruby
166
+ def require_tenant_key!(&)
167
+ Clowk.with_credentials(Tenant.current&.clowk_credentials, &)
168
+ end
169
+ ```
170
+
171
+ That second form takes a `Clowk::Credentials` object, for callers that already
172
+ have one — a row that knows how to describe itself, say. The keyword form builds
173
+ it for you, so the common case never has to name the class.
174
+
175
+ The same call works in a job, a rake task or a script; there is nothing
176
+ controller-specific about it, and `Clowk::Authenticable` deliberately adds no
177
+ second name for the same idea.
178
+
179
+ Nothing installs the filter for you. `around_action` has to wrap
180
+ `authenticate_clowk_user!`, and where it sits among your own filters is a
181
+ decision only your app can make — a gem that registers callbacks on your
182
+ controller is one you have to read to know what runs.
183
+
184
+ `Clowk.credentials` is the reader: the scoped value when one is in force, the
185
+ boot configuration otherwise. An app that never scopes anything behaves exactly
186
+ as it did before this existed, and explicitly passed arguments
187
+ (`Clowk::SDK::Client.new(secret_key: …)`) still win over both.
188
+
189
+ **There is no `Clowk.secret_key = …` setter, on purpose.** A setter has no
190
+ lifetime, so the first request that raises between the assignment and its reset
191
+ leaves that key installed process-wide — and the secret key mints HS256 tokens
192
+ for any subject, on the verification path that does not check `aud`. A leaked
193
+ scope would be an authentication bypass rather than an untidy configuration. The
194
+ block's `ensure` is what makes the lifetime a property of the API instead of the
195
+ caller's discipline. Scopes nest, and the inner one restores the outer.
196
+
197
+ Scoping is fiber-based (`ActiveSupport::IsolatedExecutionState`), so it survives
198
+ streaming responses and async adapters rather than reverting to the global
199
+ halfway through a request.
200
+
201
+ The caches were already built for this: `Clowk::Jwks` keys by URL and
202
+ `Clowk::Subdomain` keys by publishable key, so switching instances at runtime
203
+ selects a different cache entry instead of poisoning the previous one.
204
+
117
205
  ### API-only Rails apps
118
206
 
119
207
  `Clowk::Authenticable` works in `ActionController::API` controllers with no
@@ -259,6 +347,27 @@ Mounted routes exposed by the engine:
259
347
 
260
348
  When you mount the engine elsewhere, the same route set is exposed under your chosen prefix.
261
349
 
350
+ ### Turbo and the sign-in redirect
351
+
352
+ `/clowk/sign_in` answers with a redirect to your Clowk instance, which is a
353
+ different origin. **Turbo cannot follow a cross-origin redirect.** It does not
354
+ raise and it does not warn: the fetch is dropped, the page stays exactly as it
355
+ was, and the submit reads as a button that does nothing. A later manual refresh
356
+ works, which makes it look like flakiness rather than a missing opt-out.
357
+
358
+ Links are fine. A **form** that ends up at `/sign_in` — a settings screen that
359
+ turns sign-in on, say — has to opt out so the browser performs the navigation
360
+ itself:
361
+
362
+ ```erb
363
+ <%= form_with url: clowk.sign_in_path, method: :get, data: { turbo: false } do |form| %>
364
+ <%= form.submit 'Sign in' %>
365
+ <% end %>
366
+ ```
367
+
368
+ The same applies to any of your own actions that redirect to `/sign_in` at the
369
+ end: the chain still terminates cross-origin.
370
+
262
371
  ## Token sources
263
372
 
264
373
  The concern can read the token from:
@@ -405,4 +514,4 @@ Its job is to make the Rails side of Clowk integration predictable:
405
514
 
406
515
  ## License
407
516
 
408
- AGPL-3.0. See `LICENSE`.
517
+ MIT. See `LICENSE`.
data/clowk.gemspec CHANGED
@@ -11,8 +11,8 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Rails SDK for Clowk authentication"
12
12
  spec.description = "Clowk Authentication, JWT verification, and future API access"
13
13
  spec.homepage = "https://clowk.in"
14
- spec.license = "AGPL-3.0-only"
15
- spec.required_ruby_version = ">= 3.3"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1"
16
16
  spec.metadata = {
17
17
  "rubygems_mfa_required" => "true"
18
18
  }
@@ -55,6 +55,26 @@ module Clowk
55
55
  Clowk::Authenticable.install_dynamic_methods(self)
56
56
  end
57
57
 
58
+ # Per-request credentials — for apps whose keys are not a boot constant:
59
+ # an operator pastes a publishable key into a settings screen, or one
60
+ # process serves several tenants.
61
+ #
62
+ # There is no method here for that, on purpose. `Clowk.with_credentials`
63
+ # does it, works the same in a job or a rake task, and one name is one name
64
+ # to remember:
65
+ #
66
+ # around_action :require_tenant_key!
67
+ #
68
+ # def require_tenant_key!(&)
69
+ # Clowk.with_credentials(publishable_key: Tenant.current.key, &)
70
+ # end
71
+ #
72
+ # Name that filter whatever your app calls the idea, and spell the block
73
+ # however you like — `(&)`, `(&block)`, `{ yield }`. Nothing in this gem
74
+ # looks for a method of its own naming, and no macro installs a callback
75
+ # for you. `around_action` has to wrap `authenticate_clowk_user!`, and
76
+ # where it sits among your own filters is a decision only your app can make.
77
+
58
78
  def clowk_current_resource
59
79
  @clowk_current_resource ||= begin
60
80
  payload = stored_user_payload || verified_request_payload
@@ -241,9 +261,11 @@ module Clowk
241
261
  resource = clowk_current_resource
242
262
 
243
263
  return unless resource&.session_id
244
- return unless Clowk.config.secret_key.present?
264
+ secret_key = Clowk.credentials.secret_key
265
+
266
+ return unless secret_key.present?
245
267
 
246
- client = Clowk::SDK::Client.new(secret_key: Clowk.config.secret_key)
268
+ client = Clowk::SDK::Client.new(secret_key: secret_key)
247
269
  result = client.tokens.verify_with_session(token: current_token)
248
270
  status = result&.dig(:session)
249
271
 
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ module Clowk
6
+ # Credentials — which Clowk instance is answering, right here, right now.
7
+ #
8
+ # Everything else in Configuration describes the HOST: where the callback
9
+ # lives, how long HTTP waits, what the helpers are called. Those are decided
10
+ # once, at boot, by the person who wrote the initializer. These four are
11
+ # different — they name an instance, and an app that lets an operator paste a
12
+ # publishable key into a settings screen, or that serves several tenants from
13
+ # one process, has to be able to answer "which instance" per request rather
14
+ # than per process.
15
+ #
16
+ # A value object rather than four accessors because they only mean anything
17
+ # together. A publishable key from one instance beside a secret key from
18
+ # another is not a partial configuration, it is a broken one, and separate
19
+ # setters make that state reachable in between two assignments.
20
+ #
21
+ # `audience` defaults to the publishable key, which is what Clowk stamps into
22
+ # `aud` — the same rule Configuration#audience has always had, and it is
23
+ # carried here rather than left on the global for the reason this class
24
+ # exists: a SCOPED publishable key must be checked against a scoped audience.
25
+ # Reading the audience off the global while the key came from a tenant would
26
+ # verify one tenant's token against another tenant's expectation, silently,
27
+ # on the happy path. Pass `false` to switch the check off.
28
+ #
29
+ # See Clowk.with_credentials for why the scoped form is a block and not a
30
+ # setter.
31
+ # A Struct rather than a Data, and frozen by hand, so the gem keeps running on
32
+ # Ruby 3.1 — `Data` arrived in 3.2. The freeze is not decoration: credentials
33
+ # are installed into a scope and read from four places while a request runs,
34
+ # and a member somebody could reassign halfway through would be the
35
+ # half-swapped state this class exists to make unreachable.
36
+ Credentials = Struct.new(:publishable_key, :secret_key, :subdomain_url, :jwks_url, :audience,
37
+ keyword_init: true) do
38
+ # Every member optional: an app that only sets a publishable key is the
39
+ # common case, and RS256 needs nothing else.
40
+ #
41
+ # `audience` is DERIVED HERE, at construction, rather than in the reader.
42
+ # Deriving it on read would mean `to_h` and `==` saw nil while callers saw
43
+ # the publishable key — a value object that disagrees with itself. Settled
44
+ # once, so what it holds is what it means.
45
+ def initialize(publishable_key: nil, secret_key: nil, subdomain_url: nil,
46
+ jwks_url: nil, audience: nil)
47
+ super(
48
+ publishable_key: publishable_key,
49
+ secret_key: secret_key,
50
+ subdomain_url: subdomain_url,
51
+ jwks_url: jwks_url,
52
+ audience: audience.nil? ? publishable_key : audience
53
+ )
54
+
55
+ freeze
56
+ end
57
+
58
+ # The boot configuration, as credentials. This is what `Clowk.credentials`
59
+ # falls back to, so an installation that configures once and never scopes
60
+ # anything behaves exactly as it did before this existed.
61
+ def self.from(config)
62
+ new(
63
+ publishable_key: config.publishable_key,
64
+ secret_key: config.secret_key,
65
+ subdomain_url: config.subdomain_url,
66
+ jwks_url: config.jwks_url,
67
+ audience: config.audience
68
+ )
69
+ end
70
+ end
71
+
72
+ class << self
73
+ # The credentials in force for the current execution context.
74
+ #
75
+ # The scoped override if there is one, the boot configuration otherwise.
76
+ # Read this instead of Clowk.config wherever an instance is being named, so
77
+ # a consumer that never scopes anything pays nothing and a consumer that
78
+ # does is served correctly.
79
+ def credentials
80
+ ActiveSupport::IsolatedExecutionState[:clowk_credentials] || Credentials.from(config)
81
+ end
82
+
83
+ # Run a block against a particular Clowk instance.
84
+ #
85
+ # THE ONLY METHOD AN APP EVER CALLS. Attributes go straight in, so the
86
+ # common case never has to name the Credentials class:
87
+ #
88
+ # Clowk.with_credentials(publishable_key: tenant.key) do
89
+ # # sign-in URLs, JWKS, verification and the API client
90
+ # # all resolve against that instance in here
91
+ # end
92
+ #
93
+ # A prebuilt object works too, for callers that already have one — an
94
+ # ActiveRecord row that knows how to describe itself, say:
95
+ #
96
+ # Clowk.with_credentials(tenant.clowk_credentials) { … }
97
+ #
98
+ # And nil runs the block against the boot configuration, so "this request
99
+ # has no tenant" needs no branch at the call site:
100
+ #
101
+ # Clowk.with_credentials(tenant&.clowk_credentials) { … }
102
+ #
103
+ # A BLOCK, AND DELIBERATELY NOT A SETTER. `Clowk.secret_key = x` has no
104
+ # lifetime: the first request that raises between the assignment and its
105
+ # reset leaves that key installed process-wide, and the secret key mints
106
+ # HS256 tokens for any subject — Clowk::JwtVerifier routes to the symmetric
107
+ # path whenever `alg` is not RS256, and that path does not check the
108
+ # audience. So a leaked scope is not an untidy configuration, it is an
109
+ # authentication bypass. The `ensure` below is the whole API.
110
+ #
111
+ # Fiber-scoped via ActiveSupport::IsolatedExecutionState rather than
112
+ # Thread.current, so it survives the places Rails hands work to a fiber —
113
+ # streaming responses, async adapters — instead of silently reverting to
114
+ # the global halfway through a request.
115
+ #
116
+ # Nesting restores the enclosing scope, not the global.
117
+ def with_credentials(credentials = nil, **attributes)
118
+ resolved = credentials || (Credentials.new(**attributes) unless attributes.empty?)
119
+
120
+ return yield if resolved.nil?
121
+
122
+ previous = ActiveSupport::IsolatedExecutionState[:clowk_credentials]
123
+ ActiveSupport::IsolatedExecutionState[:clowk_credentials] = resolved
124
+
125
+ yield
126
+ ensure
127
+ ActiveSupport::IsolatedExecutionState[:clowk_credentials] = previous if resolved
128
+ end
129
+ end
130
+ end
data/lib/clowk/jwks.rb CHANGED
@@ -30,7 +30,7 @@ module Clowk
30
30
  end
31
31
  end
32
32
 
33
- def key_for(kid, jwks_url: Clowk.config.jwks_url)
33
+ def key_for(kid, jwks_url: Clowk.credentials.jwks_url)
34
34
  url = jwks_url || default_url
35
35
 
36
36
  key = lookup(url, kid)
@@ -48,7 +48,7 @@ module Clowk
48
48
  end
49
49
 
50
50
  def default_url
51
- base = Clowk.config.subdomain_url || Clowk::Subdomain.resolve_url!
51
+ base = Clowk.credentials.subdomain_url || Clowk::Subdomain.resolve_url!
52
52
 
53
53
  "#{base.to_s.chomp("/")}#{WELL_KNOWN_PATH}"
54
54
  rescue ConfigurationError
@@ -11,8 +11,11 @@ module Clowk
11
11
  # LEGACY_ALGORITHM — this no longer describes what the verifier accepts.
12
12
  ALGORITHM = LEGACY_ALGORITHM
13
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)
14
+ # Credentials come from Clowk.credentials, which is the scoped override when
15
+ # one is in force and the boot configuration otherwise. `issuer` stays on
16
+ # config: it describes the token format, not which instance issued it.
17
+ def initialize(secret_key: Clowk.credentials.secret_key, issuer: Clowk.config.issuer,
18
+ audience: Clowk.credentials.audience, jwks_url: Clowk.credentials.jwks_url)
16
19
  @secret_key = secret_key
17
20
  @issuer = issuer
18
21
  @audience = audience
@@ -7,8 +7,8 @@ module Clowk
7
7
  class Client
8
8
  def initialize(options = {})
9
9
  @api_base_url = options.fetch(:api_base_url, nil).presence || Clowk.config.api_base_url
10
- @secret_key = options.fetch(:secret_key, Clowk.config.secret_key)
11
- @publishable_key = options.fetch(:publishable_key, Clowk.config.publishable_key)
10
+ @secret_key = options.fetch(:secret_key, Clowk.credentials.secret_key)
11
+ @publishable_key = options.fetch(:publishable_key, Clowk.credentials.publishable_key)
12
12
  end
13
13
 
14
14
  def method_missing(method_name, *, **, &)
@@ -47,8 +47,8 @@ module Clowk
47
47
  end
48
48
 
49
49
  def initialize(options = {})
50
- @publishable_key = options.fetch(:publishable_key, Clowk.config.publishable_key)
51
- @subdomain_url = options.fetch(:subdomain_url, Clowk.config.subdomain_url)
50
+ @publishable_key = options.fetch(:publishable_key, Clowk.credentials.publishable_key)
51
+ @subdomain_url = options.fetch(:subdomain_url, Clowk.credentials.subdomain_url)
52
52
  end
53
53
 
54
54
  def resolve_url!
data/lib/clowk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clowk
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/clowk.rb CHANGED
@@ -29,11 +29,16 @@ module Clowk
29
29
 
30
30
  def reset!
31
31
  Subdomain.clear_cache! if defined?(Subdomain)
32
+ ActiveSupport::IsolatedExecutionState[:clowk_credentials] = nil
32
33
  @config = Configuration.new
33
34
  end
34
35
  end
35
36
  end
36
37
 
38
+ # After the module body: Credentials reopens `class << self` to add
39
+ # `credentials` and `with_credentials`, so the module has to exist first.
40
+ require_relative "clowk/credentials"
41
+
37
42
  require_relative "clowk/current"
38
43
  require_relative "clowk/http/response"
39
44
  require_relative "clowk/http/logger_middleware"
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.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clowk
@@ -107,6 +107,7 @@ files:
107
107
  - lib/clowk/controllers/base_controller.rb
108
108
  - lib/clowk/controllers/callbacks_controller.rb
109
109
  - lib/clowk/controllers/sessions_controller.rb
110
+ - lib/clowk/credentials.rb
110
111
  - lib/clowk/current.rb
111
112
  - lib/clowk/engine.rb
112
113
  - lib/clowk/helpers/url_helpers.rb
@@ -129,7 +130,7 @@ files:
129
130
  - lib/clowk/version.rb
130
131
  homepage: https://clowk.in
131
132
  licenses:
132
- - AGPL-3.0-only
133
+ - MIT
133
134
  metadata:
134
135
  rubygems_mfa_required: 'true'
135
136
  rdoc_options: []
@@ -139,7 +140,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
140
  requirements:
140
141
  - - ">="
141
142
  - !ruby/object:Gem::Version
142
- version: '3.3'
143
+ version: '3.1'
143
144
  required_rubygems_version: !ruby/object:Gem::Requirement
144
145
  requirements:
145
146
  - - ">="