clowk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3298d33abdf85d08387832031c8bbdb6efa36785b73c59ece84a3dec4deb064
4
- data.tar.gz: b533eb6930465ea11188d0f2f8e23758575f113972d9aea81d5bdcb87a21af32
3
+ metadata.gz: 23e593a98742fb2d99e89bcec77d30c250300f1ce40789ebadd82277548419d8
4
+ data.tar.gz: 0acb397f16bca42b0599cf2ab1e8c223010ea2ae1bb7f7031700e2cdb568f293
5
5
  SHA512:
6
- metadata.gz: 486ff44e3a9e781cad6c87584d40079ca43724c113432a76513078525b8df9a8d64a82c593a4be9bb7db87dc9c3bce7a6ec611c14fbefe2d7074904d8901b758
7
- data.tar.gz: e06afce3a59cbb24d8c388837b51582c30d2597dec2e9e08337343040ca9cbe1041b2442c573436faa2c958adf3bd6b15ac3170d82447189ae235386a16a5fd6
6
+ metadata.gz: 4b9cccf22eb2a0760dc0b949ac2021d6f509456424be2d93286af8fe47727a5853eee1dc06a8d5b934725288bc682ff34b74fe85e15c547877f9a78705d520f5
7
+ data.tar.gz: 515c0d900a8afada712f6aa2252fba6415afe3975db242c053131ae2749a127575adba4c801ca9d44ff27161cc4de660ec15fab89eba350cb61844efe55d54fa
data/README.md CHANGED
@@ -86,7 +86,7 @@ Clowk.configure do |config|
86
86
  config.after_sign_in_path = '/'
87
87
  config.after_sign_out_path = '/'
88
88
 
89
- config.api_base_url = 'https://api.clowk.dev/client/v1'
89
+ config.api_base_url = 'https://api.clowk.dev/api/v1'
90
90
  config.callback_path = '/clowk/oauth/callback'
91
91
  config.mount_path = '/clowk'
92
92
 
@@ -362,4 +362,4 @@ Its job is to make the Rails side of Clowk integration predictable:
362
362
 
363
363
  ## License
364
364
 
365
- MIT. See `LICENSE`.
365
+ AGPL-3.0. See `LICENSE`.
data/clowk.gemspec CHANGED
@@ -1,31 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'lib/clowk/version'
3
+ require_relative "lib/clowk/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = 'clowk'
6
+ spec.name = "clowk"
7
7
  spec.version = Clowk::VERSION
8
- spec.authors = ['Clowk']
9
- spec.email = ['support@clowk.in']
8
+ spec.authors = ["Clowk"]
9
+ spec.email = ["support@clowk.in"]
10
10
 
11
- spec.summary = 'Rails SDK for Clowk authentication'
12
- spec.description = 'Clowk Authentication, JWT verification, and future API access'
13
- spec.homepage = 'https://clowk.in'
14
- spec.license = 'AGPL-3.0'
15
- spec.required_ruby_version = '>= 3.3'
11
+ spec.summary = "Rails SDK for Clowk authentication"
12
+ spec.description = "Clowk Authentication, JWT verification, and future API access"
13
+ spec.homepage = "https://clowk.in"
14
+ spec.license = "AGPL-3.0-only"
15
+ spec.required_ruby_version = ">= 3.3"
16
16
  spec.metadata = {
17
- 'rubygems_mfa_required' => 'true'
17
+ "rubygems_mfa_required" => "true"
18
18
  }
19
19
 
20
20
  spec.files = Dir.chdir(__dir__) do
21
- Dir['README.md', 'clowk.gemspec', 'config/routes.rb', 'lib/**/*.rb']
21
+ Dir["README.md", "clowk.gemspec", "config/routes.rb", "lib/**/*.rb"]
22
22
  end
23
23
 
24
- spec.require_paths = ['lib']
24
+ spec.require_paths = ["lib"]
25
25
 
26
- spec.add_dependency 'activesupport', '>= 7.0'
27
- spec.add_dependency 'jwt', '>= 2.7', '< 3.0'
28
- spec.add_dependency 'railties', '>= 7.0'
26
+ spec.add_dependency "activesupport", ">= 7.0"
27
+ spec.add_dependency "jwt", ">= 2.7", "< 3.0"
28
+ spec.add_dependency "railties", ">= 7.0"
29
29
 
30
- spec.add_development_dependency 'rspec', '>= 3.13', '< 4.0'
30
+ spec.add_development_dependency "rspec", ">= 3.13", "< 4.0"
31
+ spec.add_development_dependency "standard", ">= 1.0"
31
32
  end
data/config/routes.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Clowk::Engine.routes.draw do
4
- get '/sign_in', to: 'sessions#new', as: :sign_in
5
- get '/sign_up', to: 'sessions#sign_up', as: :sign_up
6
- match '/sign_out', to: 'sessions#destroy', via: %i(get delete), as: :sign_out
7
- get '/oauth/callback', to: 'callbacks#show', as: :auth_callback
4
+ get "/sign_in", to: "sessions#new", as: :sign_in
5
+ get "/sign_up", to: "sessions#sign_up", as: :sign_up
6
+ match "/sign_out", to: "sessions#destroy", via: %i[get delete], as: :sign_out
7
+ get "/oauth/callback", to: "callbacks#show", as: :auth_callback
8
8
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/concern'
3
+ require "active_support/concern"
4
4
 
5
5
  module Clowk
6
6
  module Authenticable
@@ -13,22 +13,37 @@ module Clowk
13
13
  signed_in_method = :"#{scope}_signed_in?"
14
14
 
15
15
  enforce_session_method = :"#{scope}_enforce_session!"
16
+ sign_out_method = :"#{scope}_sign_out!"
16
17
 
17
18
  base.class_eval do
18
- define_method(current_method) do
19
- clowk_current_resource
19
+ unless current_method == :clowk_current_resource
20
+ define_method(current_method) do
21
+ clowk_current_resource
22
+ end
20
23
  end
21
24
 
22
- define_method(authenticate_method) do
23
- clowk_authenticate!
25
+ unless authenticate_method == :clowk_authenticate!
26
+ define_method(authenticate_method) do
27
+ clowk_authenticate!
28
+ end
24
29
  end
25
30
 
26
- define_method(signed_in_method) do
27
- clowk_current_resource.present?
31
+ unless signed_in_method == :clowk_signed_in?
32
+ define_method(signed_in_method) do
33
+ clowk_signed_in?
34
+ end
28
35
  end
29
36
 
30
- define_method(enforce_session_method) do
31
- clowk_enforce_session!
37
+ unless enforce_session_method == :clowk_enforce_session!
38
+ define_method(enforce_session_method) do
39
+ clowk_enforce_session!
40
+ end
41
+ end
42
+
43
+ unless sign_out_method == :clowk_sign_out!
44
+ define_method(sign_out_method) do
45
+ clowk_sign_out!
46
+ end
32
47
  end
33
48
 
34
49
  helper_method current_method, authenticate_method, signed_in_method, :current_token if respond_to?(:helper_method)
@@ -47,7 +62,7 @@ module Clowk
47
62
  end
48
63
 
49
64
  def current_token
50
- stored_session&.dig('token') || extracted_token
65
+ stored_session&.dig("token") || extracted_token
51
66
  end
52
67
 
53
68
  def clowk_signed_in?
@@ -59,7 +74,7 @@ module Clowk
59
74
  end
60
75
 
61
76
  def clowk_session_active?
62
- clowk_session_status&.dig(:status) == 'active'
77
+ clowk_session_status&.dig(:status) == "active"
63
78
  end
64
79
 
65
80
  def clowk_enforce_session!
@@ -74,21 +89,21 @@ module Clowk
74
89
  return
75
90
  end
76
91
 
77
- if request.format.json?
78
- render json: { error: 'Session expired or inactive' }, status: :unauthorized
79
- else
80
- redirect_to clowk_sign_in_path(return_to: request.fullpath)
81
- end
92
+ clowk_handle_expired_session(session_info)
82
93
  end
83
94
 
84
95
  def clowk_authenticate!
85
- return clowk_current_resource if clowk_signed_in?
86
-
87
- if request.format.json?
88
- render json: { error: 'Unauthorized' }, status: :unauthorized
89
- else
90
- redirect_to clowk_sign_in_path(return_to: request.fullpath)
96
+ return clowk_handle_unauthenticated unless clowk_signed_in?
97
+
98
+ # A valid token proves who signed in, not that the session still stands —
99
+ # revocation lives server-side. Opt in with config.enforce_active_session
100
+ # to pay a lookup (cached for session_status_ttl) on every authentication.
101
+ if Clowk.config.enforce_active_session
102
+ clowk_enforce_session!
103
+ return if respond_to?(:performed?) && performed?
91
104
  end
105
+
106
+ clowk_current_resource
92
107
  end
93
108
 
94
109
  def clowk_sign_out!
@@ -100,6 +115,22 @@ module Clowk
100
115
 
101
116
  private
102
117
 
118
+ def clowk_handle_unauthenticated
119
+ if request.format.json?
120
+ render json: {error: "Unauthorized"}, status: :unauthorized
121
+ else
122
+ redirect_to clowk_sign_in_path(return_to: request.fullpath)
123
+ end
124
+ end
125
+
126
+ def clowk_handle_expired_session(_session_info)
127
+ if request.format.json?
128
+ render json: {error: "Session expired or inactive"}, status: :unauthorized
129
+ else
130
+ redirect_to clowk_sign_in_path(return_to: request.fullpath)
131
+ end
132
+ end
133
+
103
134
  def verified_request_payload
104
135
  return unless extracted_token
105
136
 
@@ -111,8 +142,13 @@ module Clowk
111
142
  nil
112
143
  end
113
144
 
145
+ # Bearer header or cookie only — never the query string. A valid token in a
146
+ # URL would otherwise sign the visitor in on ANY path, bypassing the state
147
+ # check that makes the OAuth callback safe, and would stay replayable for the
148
+ # token's full lifetime anywhere the URL was logged. CallbacksController
149
+ # reads params directly, after validating state.
114
150
  def extracted_token
115
- @extracted_token ||= Clowk::Middleware::TokenExtractor.new(request).call
151
+ @extracted_token ||= Clowk::Middleware::TokenExtractor.new(request, token_param: nil).call
116
152
  end
117
153
 
118
154
  def stored_session
@@ -123,7 +159,7 @@ module Clowk
123
159
  end
124
160
 
125
161
  def stored_user_payload
126
- payload = stored_session&.dig('user') || stored_session&.dig(:user)
162
+ payload = stored_session&.dig("user") || stored_session&.dig(:user)
127
163
  payload&.deep_symbolize_keys
128
164
  end
129
165
 
@@ -143,9 +179,9 @@ module Clowk
143
179
  end
144
180
 
145
181
  def resolve_session_status
146
- cached = stored_session&.dig('session_status') || stored_session&.dig(:session_status)
182
+ cached = stored_session&.dig("session_status") || stored_session&.dig(:session_status)
147
183
 
148
- return cached&.deep_symbolize_keys if cached
184
+ return cached&.deep_symbolize_keys if cached && clowk_session_status_fresh?
149
185
 
150
186
  resource = clowk_current_resource
151
187
 
@@ -156,9 +192,29 @@ module Clowk
156
192
  result = client.tokens.verify_with_session(token: current_token)
157
193
  status = result&.dig(:session)
158
194
 
159
- session[Clowk.config.session_key] = stored_session.merge('session_status' => status) if status && stored_session
195
+ if status && stored_session
196
+ session[Clowk.config.session_key] = stored_session.merge(
197
+ "session_status" => status,
198
+ "session_status_checked_at" => Time.now.to_i
199
+ )
200
+ end
160
201
 
161
202
  status
203
+ rescue Clowk::InvalidTokenError
204
+ nil
205
+ end
206
+
207
+ # Whether the cached status may still be trusted. Timestamped alongside the
208
+ # payload rather than inside it, so what we hand back stays exactly what the
209
+ # API returned.
210
+ def clowk_session_status_fresh?
211
+ ttl = Clowk.config.session_status_ttl.to_i
212
+ return false unless ttl.positive?
213
+
214
+ checked_at = (stored_session&.dig("session_status_checked_at") ||
215
+ stored_session&.dig(:session_status_checked_at)).to_i
216
+
217
+ checked_at.positive? && (Time.now.to_i - checked_at) < ttl
162
218
  end
163
219
  end
164
220
  end
@@ -2,9 +2,8 @@
2
2
 
3
3
  module Clowk
4
4
  class Configuration
5
+ attr_accessor :api_base_url
5
6
  attr_accessor :app_base_url
6
- attr_accessor :after_sign_in_path
7
- attr_accessor :after_sign_out_path
8
7
  attr_accessor :callback_path
9
8
  attr_accessor :cookie_key
10
9
  attr_accessor :http_logger
@@ -23,26 +22,65 @@ module Clowk
23
22
  attr_accessor :token_param
24
23
  attr_accessor :enforce_active_session
25
24
  attr_accessor :on_session_expired
25
+ attr_accessor :session_status_ttl
26
26
 
27
27
  def initialize
28
- @app_base_url = 'https://app.clowk.in'
29
- @after_sign_in_path = '/'
30
- @after_sign_out_path = '/'
31
- @mount_path = '/clowk'
32
- @callback_path = '/clowk/oauth/callback'
33
- @cookie_key = 'clowk_token'
28
+ @api_base_url = "https://api.clowk.dev/api/v1"
29
+ @app_base_url = "https://app.clowk.in"
30
+ @after_sign_in_path = "/"
31
+ @after_sign_out_path = "/"
32
+ @mount_path = "/clowk"
33
+ @callback_path = "/clowk/oauth/callback"
34
+ @cookie_key = "clowk_token"
34
35
  @http_logger = nil
35
36
  @http_open_timeout = 5
36
37
  @http_read_timeout = 10
37
38
  @http_retry_attempts = 2
38
39
  @http_retry_interval = 0.05
39
40
  @http_write_timeout = 10
40
- @issuer = 'clowk'
41
+ @issuer = "clowk"
41
42
  @session_key = :clowk
42
43
  @prefix_by = :clowk
43
44
  @token_param = :token
44
45
  @enforce_active_session = false
45
46
  @on_session_expired = nil
47
+ # How long a fetched session status stays trusted, in seconds. Without a
48
+ # TTL the first lookup would be cached for the life of the Rails session,
49
+ # which silently turns every later enforcement call into a no-op. Set 0 to
50
+ # check on every call.
51
+ @session_status_ttl = 300
52
+ end
53
+
54
+ def after_sign_in_path
55
+ resolve_path(@after_sign_in_path)
56
+ end
57
+
58
+ def after_sign_out_path
59
+ resolve_path(@after_sign_out_path)
60
+ end
61
+
62
+ attr_writer :after_sign_in_path
63
+
64
+ attr_writer :after_sign_out_path
65
+
66
+ def validate!
67
+ errors = []
68
+ errors << "secret_key must be a String" unless @secret_key.is_a?(String) || @secret_key.nil?
69
+ errors << "http_open_timeout must be Numeric" unless @http_open_timeout.is_a?(Numeric)
70
+ errors << "http_read_timeout must be Numeric" unless @http_read_timeout.is_a?(Numeric)
71
+ errors << "http_write_timeout must be Numeric" unless @http_write_timeout.is_a?(Numeric)
72
+ errors << "http_retry_attempts must be a non-negative Integer" unless @http_retry_attempts.is_a?(Integer) && @http_retry_attempts >= 0
73
+ errors << "session_status_ttl must be a non-negative Integer" unless @session_status_ttl.is_a?(Integer) && @session_status_ttl >= 0
74
+
75
+ raise ConfigurationError, errors.join(", ") if errors.any?
76
+
77
+ true
78
+ end
79
+
80
+ private
81
+
82
+ def resolve_path(path_or_callable)
83
+ path_or_callable.respond_to?(:call) ? path_or_callable.call : path_or_callable
46
84
  end
47
85
  end
48
86
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
4
- require 'uri'
3
+ require "securerandom"
4
+ require "uri"
5
5
 
6
6
  module Clowk
7
7
  class BaseController < ActionController::Base
@@ -13,18 +13,18 @@ module Clowk
13
13
  private
14
14
 
15
15
  def redirect_back_or(default, return_to: params[:return_to])
16
- redirect_target = safe_redirect_path(return_to) || safe_redirect_path(default) || '/'
16
+ redirect_target = safe_redirect_path(return_to) || safe_redirect_path(default) || "/"
17
17
 
18
18
  redirect_to redirect_target
19
19
  end
20
20
 
21
21
  def start_clowk_auth_flow!(return_to: nil)
22
- sanitized_return_to = safe_redirect_path(return_to) || safe_redirect_path(Clowk.config.after_sign_in_path) || '/'
22
+ sanitized_return_to = safe_redirect_path(return_to) || safe_redirect_path(Clowk.config.after_sign_in_path) || "/"
23
23
  state = SecureRandom.hex(32)
24
24
 
25
25
  session[:clowk_auth_flow] = {
26
- 'state' => state,
27
- 'return_to' => sanitized_return_to
26
+ "state" => state,
27
+ "return_to" => sanitized_return_to
28
28
  }
29
29
 
30
30
  state
@@ -38,9 +38,9 @@ module Clowk
38
38
  end
39
39
 
40
40
  def validate_clowk_state!(expected_state, actual_state)
41
- raise Clowk::InvalidStateError, 'missing state' if actual_state.blank?
42
- raise Clowk::InvalidStateError, 'missing state' if expected_state.blank?
43
- raise Clowk::InvalidStateError, 'invalid state' unless state_matches?(expected_state, actual_state)
41
+ raise Clowk::InvalidStateError, "missing state" if actual_state.blank?
42
+ raise Clowk::InvalidStateError, "missing state" if expected_state.blank?
43
+ raise Clowk::InvalidStateError, "invalid state" unless state_matches?(expected_state, actual_state)
44
44
  end
45
45
 
46
46
  def state_matches?(expected_state, actual_state)
@@ -53,7 +53,7 @@ module Clowk
53
53
  value = candidate.to_s
54
54
  return if value.empty?
55
55
 
56
- return value if value.start_with?('/') && !value.start_with?('//')
56
+ return value if value.start_with?("/") && !value.start_with?("//")
57
57
 
58
58
  uri = URI.parse(value)
59
59
  return unless uri.host == request.host && uri.scheme == request.scheme
@@ -4,20 +4,21 @@ module Clowk
4
4
  class CallbacksController < BaseController
5
5
  def show
6
6
  flow = consume_clowk_auth_flow!
7
- validate_clowk_state!(flow['state'], params[:state])
7
+ validate_clowk_state!(flow["state"], params[:state])
8
8
 
9
9
  token = params[Clowk.config.token_param]
10
- raise Clowk::InvalidTokenError, 'missing token' if token.blank?
10
+ raise Clowk::InvalidTokenError, "missing token" if token.blank?
11
11
 
12
12
  payload = Clowk::JwtVerifier.new.verify(token)
13
- return_to = flow['return_to']
13
+ return_to = flow["return_to"]
14
14
 
15
15
  reset_clowk_session!
16
16
  persist_clowk_session(token, payload)
17
17
 
18
18
  redirect_back_or(Clowk.config.after_sign_in_path, return_to:)
19
19
  rescue Clowk::InvalidTokenError, Clowk::InvalidStateError => e
20
- flash[:alert] = "Clowk authentication failed: #{e.message}"
20
+ Rails.logger.error("[Clowk] Authentication failed: #{e.class} - #{e.message}")
21
+ flash[:alert] = "Authentication failed. Please try again."
21
22
 
22
23
  redirect_back_or(Clowk.config.after_sign_out_path, return_to: nil)
23
24
  end
data/lib/clowk/current.rb CHANGED
@@ -47,5 +47,19 @@ module Clowk
47
47
  def to_h
48
48
  attributes.merge(id: id)
49
49
  end
50
+
51
+ def ==(other)
52
+ return false unless other.is_a?(Current)
53
+
54
+ to_h == other.to_h
55
+ end
56
+
57
+ def eql?(other)
58
+ self == other
59
+ end
60
+
61
+ def hash
62
+ to_h.hash
63
+ end
50
64
  end
51
- end
65
+ end
data/lib/clowk/engine.rb CHANGED
@@ -4,7 +4,7 @@ module Clowk
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Clowk
6
6
 
7
- initializer 'clowk.helpers' do
7
+ initializer "clowk.helpers" do
8
8
  ActiveSupport.on_load(:action_controller_base) do
9
9
  include Clowk::Helpers::UrlHelpers
10
10
  end
@@ -1,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Clowk
6
4
  module Helpers
7
5
  module UrlHelpers
8
6
  def clowk_sign_in_path(return_to: nil)
9
- append_query(clowk_local_path('/sign_in'), return_to:)
7
+ append_query(clowk_local_path("/sign_in"), return_to:)
10
8
  end
11
9
 
12
10
  def clowk_sign_up_path(return_to: nil)
13
- append_query(clowk_local_path('/sign_up'), return_to:)
11
+ append_query(clowk_local_path("/sign_up"), return_to:)
14
12
  end
15
13
 
16
14
  def clowk_sign_out_path(return_to: nil)
17
- append_query(clowk_local_path('/sign_out'), return_to:)
15
+ append_query(clowk_local_path("/sign_out"), return_to:)
18
16
  end
19
17
 
20
18
  def clowk_callback_url(return_to: nil, state: nil)
@@ -22,18 +20,18 @@ module Clowk
22
20
  end
23
21
 
24
22
  def clowk_sign_in_url(redirect_to: nil, return_to: nil, state: nil)
25
- clowk_remote_auth_url('sign-in', redirect_to:, return_to:, state:)
23
+ clowk_remote_auth_url("sign-in", redirect_to:, return_to:, state:)
26
24
  end
27
25
 
28
26
  def clowk_sign_up_url(redirect_to: nil, return_to: nil, state: nil)
29
- clowk_remote_auth_url('sign-up', redirect_to:, return_to:, state:)
27
+ clowk_remote_auth_url("sign-up", redirect_to:, return_to:, state:)
30
28
  end
31
29
 
32
30
  private
33
31
 
34
32
  def clowk_remote_auth_url(action, redirect_to:, return_to:, state:)
35
33
  callback_url = clowk_callback_url(return_to: redirect_to || return_to, state:)
36
- query = { redirect_uri: callback_url }
34
+ query = {redirect_uri: callback_url}
37
35
 
38
36
  append_query("#{clowk_instance_base_url}/#{action}", query)
39
37
  end
@@ -50,7 +48,7 @@ module Clowk
50
48
  filtered = params.compact.reject { |_key, value| value.respond_to?(:empty?) && value.empty? }
51
49
  return url if filtered.empty?
52
50
 
53
- separator = url.include?('?') ? '&' : '?'
51
+ separator = url.include?("?") ? "&" : "?"
54
52
  "#{url}#{separator}#{Rack::Utils.build_query(filtered)}"
55
53
  end
56
54
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'net/http'
5
- require 'uri'
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
6
 
7
7
  module Clowk
8
8
  class Http
@@ -121,7 +121,7 @@ module Clowk
121
121
 
122
122
  request.body = JSON.generate(env[:body]) unless env[:body].nil?
123
123
 
124
- raw_response = Net::HTTP.start(env[:uri].host, env[:uri].port, use_ssl: env[:uri].scheme == 'https') do |http|
124
+ raw_response = Net::HTTP.start(env[:uri].host, env[:uri].port, use_ssl: env[:uri].scheme == "https") do |http|
125
125
  apply_timeouts(http, env[:timeouts])
126
126
  http.request(request)
127
127
  end
@@ -144,12 +144,12 @@ module Clowk
144
144
  end
145
145
 
146
146
  def normalize_path(path)
147
- path.to_s.start_with?('/') ? path : "/#{path}"
147
+ path.to_s.start_with?("/") ? path : "/#{path}"
148
148
  end
149
149
 
150
150
  def join_paths(base_path, extra_path)
151
- segments = [base_path.to_s, extra_path.to_s].map { |segment| segment.gsub(%r{^/+|/+$}, '') }.reject(&:empty?)
152
- "/#{segments.join('/')}"
151
+ segments = [base_path.to_s, extra_path.to_s].map { |segment| segment.gsub(%r{^/+|/+$}, "") }.reject(&:empty?)
152
+ "/#{segments.join("/")}"
153
153
  end
154
154
 
155
155
  def apply_headers(request, request_headers)
@@ -167,8 +167,8 @@ module Clowk
167
167
 
168
168
  def default_headers
169
169
  {
170
- 'Accept' => 'application/json',
171
- 'Content-Type' => 'application/json'
170
+ "Accept" => "application/json",
171
+ "Content-Type" => "application/json"
172
172
  }
173
173
  end
174
174
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
3
+ require "logger"
4
4
 
5
5
  module Clowk
6
6
  class Http
@@ -22,7 +22,8 @@ module Clowk
22
22
  attr_reader :app, :logger
23
23
 
24
24
  class NullLogger
25
- def info(*); end
25
+ def info(*)
26
+ end
26
27
  end
27
28
  end
28
29
  end
@@ -27,17 +27,17 @@ module Clowk
27
27
  body: body,
28
28
  body_parsed: body_parsed,
29
29
  headers: headers,
30
- success?: success?
30
+ success: success?
31
31
  }
32
32
  end
33
33
 
34
34
  def ==(other)
35
- if other.respond_to?(:to_h)
36
- to_h == other.to_h
35
+ to_h == if other.respond_to?(:to_h)
36
+ other.to_h
37
37
  else
38
- to_h == other
38
+ other
39
39
  end
40
40
  end
41
41
  end
42
42
  end
43
- end
43
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
3
+ require "net/http"
4
4
 
5
5
  module Clowk
6
6
  class Http
@@ -44,4 +44,4 @@ module Clowk
44
44
  attr_reader :app, :logger
45
45
  end
46
46
  end
47
- end
47
+ end
@@ -27,4 +27,4 @@ module Clowk
27
27
  attr_reader :app, :logger, :open_timeout, :read_timeout, :write_timeout
28
28
  end
29
29
  end
30
- end
30
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jwt'
3
+ require "jwt"
4
4
 
5
5
  module Clowk
6
6
  class JwtVerifier
7
- ALGORITHM = 'HS256'
7
+ ALGORITHM = "HS256"
8
8
 
9
9
  def initialize(secret_key: Clowk.config.secret_key, issuer: Clowk.config.issuer)
10
10
  @secret_key = secret_key
@@ -12,9 +12,9 @@ module Clowk
12
12
  end
13
13
 
14
14
  def verify(token)
15
- raise ConfigurationError, 'missing Clowk secret_key' if @secret_key.to_s.empty?
15
+ raise ConfigurationError, "missing Clowk secret_key" if @secret_key.to_s.empty?
16
16
 
17
- options = { algorithm: ALGORITHM }
17
+ options = {algorithm: ALGORITHM}
18
18
  options[:iss] = @issuer if @issuer
19
19
  options[:verify_iss] = @issuer.present?
20
20
 
@@ -17,8 +17,14 @@ module Clowk
17
17
 
18
18
  attr_reader :request, :token_param, :cookie_key
19
19
 
20
+ # Opt-in only. Reading a token from the query string on every request lets
21
+ # a link like /anything?token=<jwt> establish a session without ever
22
+ # passing the callback's `state` check, so callers that establish sessions
23
+ # pass token_param: nil. The callback reads params itself, after state.
20
24
  def token_from_params
21
- params = request.respond_to?(:params) && request.params ? request.params : {}
25
+ return if token_param.nil?
26
+
27
+ params = (request.respond_to?(:params) && request.params) ? request.params : {}
22
28
  params[token_param.to_s].presence
23
29
  end
24
30
 
@@ -26,8 +32,8 @@ module Clowk
26
32
  header = request.authorization.to_s
27
33
  return if header.empty?
28
34
 
29
- scheme, token = header.split(' ', 2)
30
- return unless scheme.to_s.casecmp('Bearer').zero?
35
+ scheme, token = header.split(" ", 2)
36
+ return unless scheme.to_s.casecmp("Bearer").zero?
31
37
 
32
38
  token.presence
33
39
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/inflector'
3
+ require "active_support/inflector"
4
4
 
5
5
  module Clowk
6
6
  module SDK
7
7
  class Client
8
8
  def initialize(options = {})
9
- @api_base_url = options.fetch(:api_base_url, nil).presence || derive_api_base_url
9
+ @api_base_url = options.fetch(:api_base_url, nil).presence || Clowk.config.api_base_url
10
10
  @secret_key = options.fetch(:secret_key, Clowk.config.secret_key)
11
11
  @publishable_key = options.fetch(:publishable_key, Clowk.config.publishable_key)
12
12
  end
@@ -18,7 +18,7 @@ module Clowk
18
18
 
19
19
  return super unless Clowk::SDK.const_defined?(resource_class_name)
20
20
 
21
- resource_ivar = "@#{method_name}"
21
+ resource_ivar = :"@#{resource_class_name}"
22
22
  return instance_variable_get(resource_ivar) if instance_variable_defined?(resource_ivar)
23
23
 
24
24
  resource_class = Clowk::SDK.const_get(resource_class_name)
@@ -65,13 +65,6 @@ module Clowk
65
65
 
66
66
  attr_reader :api_base_url, :publishable_key, :secret_key
67
67
 
68
- def derive_api_base_url
69
- base = Clowk.config.subdomain_url.to_s.strip
70
- return if base.empty?
71
-
72
- "#{base.sub(%r{/$}, '')}/api/v1"
73
- end
74
-
75
68
  def http
76
69
  @http ||= Clowk::Http.new(
77
70
  base_url: api_base_url,
@@ -87,8 +80,8 @@ module Clowk
87
80
 
88
81
  def default_headers
89
82
  {}.tap do |headers|
90
- headers['X-Clowk-Secret-Key'] = secret_key if secret_key.present?
91
- headers['X-Clowk-Publishable-Key'] = publishable_key if publishable_key.present?
83
+ headers["X-Clowk-Secret-Key"] = secret_key if secret_key.present?
84
+ headers["X-Clowk-Publishable-Key"] = publishable_key if publishable_key.present?
92
85
  end
93
86
  end
94
87
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
3
+ require "erb"
4
4
 
5
5
  module Clowk
6
6
  module SDK
7
7
  class Resource
8
8
  def self.resource_path
9
- raise NotImplementedError, 'resource_path must be implemented'
9
+ raise NotImplementedError, "resource_path must be implemented"
10
10
  end
11
11
 
12
12
  def initialize(client)
@@ -31,10 +31,10 @@ module Clowk
31
31
  # @return [Clowk::Http::Response]
32
32
  def search(raw_query = nil, **filters)
33
33
  query = if raw_query.is_a?(String)
34
- raw_query
35
- else
36
- filters.map { |k, v| "#{k}:#{v}" }.join(' ')
37
- end
34
+ raw_query
35
+ else
36
+ filters.map { |k, v| "#{k}:#{v}" }.join(" ")
37
+ end
38
38
 
39
39
  client.get("#{self.class.resource_path}/search?query=#{ERB::Util.url_encode(query)}")
40
40
  end
@@ -4,12 +4,16 @@ module Clowk
4
4
  module SDK
5
5
  class Session < Resource
6
6
  def self.resource_path
7
- 'sessions'
7
+ "sessions"
8
8
  end
9
9
 
10
- # @param email [String] Email to search for (ILIKE match)
10
+ # @param raw_query [String, nil] Raw query string (forwarded to the base search)
11
+ # @param email [String, nil] Email to search for via the legacy ILIKE endpoint
12
+ # @param filters [Hash] Keyword filters (forwarded to the base search)
11
13
  # @return [Clowk::Http::Response]
12
- def search(email:)
14
+ def search(raw_query = nil, email: nil, **filters)
15
+ return super(raw_query, **filters) unless email
16
+
13
17
  client.get("#{self.class.resource_path}/search?email=#{ERB::Util.url_encode(email)}")
14
18
  end
15
19
 
@@ -4,7 +4,7 @@ module Clowk
4
4
  module SDK
5
5
  class SessionConfig < Resource
6
6
  def self.resource_path
7
- 'session_config'
7
+ "session_config"
8
8
  end
9
9
 
10
10
  def fetch
@@ -4,7 +4,7 @@ module Clowk
4
4
  module SDK
5
5
  class Subdomain < Resource
6
6
  def self.resource_path
7
- 'instances'
7
+ "instances"
8
8
  end
9
9
 
10
10
  def find_by_pk(key = nil)
@@ -4,16 +4,21 @@ module Clowk
4
4
  module SDK
5
5
  class Token < Resource
6
6
  def self.resource_path
7
- 'tokens'
7
+ "tokens"
8
8
  end
9
9
 
10
10
  def verify(token:)
11
- client.post("#{self.class.resource_path}/verify", { token: token })
11
+ client.post("#{self.class.resource_path}/verify", {token: token})
12
12
  end
13
13
 
14
14
  def verify_with_session(token:)
15
15
  response = verify(token:)
16
- data = response.body_parsed&.dig('data') || response.body_parsed
16
+
17
+ unless response.success?
18
+ raise Clowk::InvalidTokenError, response.body_parsed&.dig("error") || "token verification failed"
19
+ end
20
+
21
+ data = response.body_parsed&.dig("data") || response.body_parsed
17
22
 
18
23
  data&.deep_symbolize_keys
19
24
  end
@@ -4,7 +4,7 @@ module Clowk
4
4
  module SDK
5
5
  class User < Resource
6
6
  def self.resource_path
7
- 'users'
7
+ "users"
8
8
  end
9
9
  end
10
10
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'uri'
3
+ require "uri"
4
4
 
5
5
  module Clowk
6
6
  class Subdomain
7
- API_BASE_URL = 'https://api.clowk.dev/api/v1'
8
7
  CACHE_TTL = 60
9
- DEFAULT_SUBDOMAIN_BASE = 'clowk.dev'
8
+ DEFAULT_SUBDOMAIN_BASE = "clowk.dev"
9
+
10
+ @cache_mutex = Mutex.new
10
11
 
11
12
  class << self
12
13
  def resolve_url!(...)
@@ -14,24 +15,28 @@ module Clowk
14
15
  end
15
16
 
16
17
  def clear_cache!
17
- @cache = {}
18
+ @cache_mutex.synchronize { @cache = {} }
18
19
  end
19
20
 
20
21
  def read_cache(key)
21
- entry = cache[key]
22
+ @cache_mutex.synchronize do
23
+ entry = cache[key]
22
24
 
23
- return unless entry
24
- return entry[:value] if entry[:expires_at] > Time.now
25
+ return unless entry
26
+ return entry[:value] if entry[:expires_at] > Time.now
25
27
 
26
- cache.delete(key)
27
- nil
28
+ cache.delete(key)
29
+ nil
30
+ end
28
31
  end
29
32
 
30
33
  def write_cache(key, value, ttl:)
31
- cache[key] = {
32
- value: value,
33
- expires_at: Time.now + ttl
34
- }
34
+ @cache_mutex.synchronize do
35
+ cache[key] = {
36
+ value: value,
37
+ expires_at: Time.now + ttl
38
+ }
39
+ end
35
40
  end
36
41
 
37
42
  private
@@ -50,7 +55,7 @@ module Clowk
50
55
  return resolve_from_key if publishable_key.present?
51
56
  return normalize_url(subdomain_url) if subdomain_url.present?
52
57
 
53
- raise ConfigurationError, 'set publishable_key or subdomain_url to build Clowk URLs'
58
+ raise ConfigurationError, "set publishable_key or subdomain_url to build Clowk URLs"
54
59
  end
55
60
 
56
61
  private
@@ -64,7 +69,7 @@ module Clowk
64
69
  response = client.subdomains.find_by_pk(publishable_key)
65
70
  resolved = extract_url_from_instance(response.body_parsed)
66
71
 
67
- raise ConfigurationError, 'could not resolve subdomain_url from publishable_key' if resolved.blank?
72
+ raise ConfigurationError, "could not resolve subdomain_url from publishable_key" if resolved.blank?
68
73
 
69
74
  self.class.write_cache(cache_key, resolved, ttl: CACHE_TTL)
70
75
  resolved
@@ -78,27 +83,27 @@ module Clowk
78
83
  return if payload.blank?
79
84
 
80
85
  root = payload.is_a?(Hash) ? payload : {}
81
- instance_data = if root['instance'].is_a?(Hash)
82
- root['instance']
83
- elsif root['data'].is_a?(Hash)
84
- root['data']
85
- else
86
- root
87
- end
88
-
89
- explicit_url = instance_data['url'] || instance_data['subdomain_url'] || instance_data['instance_url']
86
+ instance_data = if root["instance"].is_a?(Hash)
87
+ root["instance"]
88
+ elsif root["data"].is_a?(Hash)
89
+ root["data"]
90
+ else
91
+ root
92
+ end
93
+
94
+ explicit_url = instance_data["url"] || instance_data["subdomain_url"] || instance_data["instance_url"]
90
95
  return normalize_url(explicit_url) if explicit_url.present?
91
96
 
92
- host = instance_data['host'] || instance_data['domain'] || instance_data['hostname']
97
+ host = instance_data["host"] || instance_data["domain"] || instance_data["hostname"]
93
98
  return normalize_url(host_to_url(host)) if host.present?
94
99
 
95
- subdomain = instance_data['subdomain']
100
+ subdomain = instance_data["subdomain"]
96
101
  normalize_url("https://#{subdomain}.#{default_subdomain_base}") if subdomain.present?
97
102
  end
98
103
 
99
104
  def host_to_url(host)
100
105
  value = host.to_s
101
- return value if value.start_with?('http://', 'https://')
106
+ return value if value.start_with?("http://", "https://")
102
107
 
103
108
  "https://#{value}"
104
109
  end
@@ -110,17 +115,17 @@ module Clowk
110
115
  uri = URI.parse(configured)
111
116
  return DEFAULT_SUBDOMAIN_BASE if uri.host.blank?
112
117
 
113
- uri.host.split('.').drop(1).join('.')
118
+ uri.host.split(".").drop(1).join(".")
114
119
  rescue URI::InvalidURIError
115
120
  DEFAULT_SUBDOMAIN_BASE
116
121
  end
117
122
 
118
123
  def normalize_url(value)
119
- value.to_s.sub(%r{/$}, '')
124
+ value.to_s.sub(%r{/$}, "")
120
125
  end
121
126
 
122
127
  def client
123
- @client ||= Clowk::SDK::Client.new(api_base_url: API_BASE_URL)
128
+ @client ||= Clowk::SDK::Client.new(api_base_url: Clowk.config.api_base_url)
124
129
  end
125
130
  end
126
131
  end
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.3.0'
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/clowk.rb CHANGED
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails'
4
- require 'rails/engine'
5
- require 'action_controller/railtie'
6
- require 'active_support'
7
- require 'active_support/core_ext/hash'
8
- require 'active_support/core_ext/object/blank'
9
- require 'rack'
3
+ require "rails"
4
+ require "rails/engine"
5
+ require "action_controller/railtie"
6
+ require "active_support"
7
+ require "active_support/core_ext/hash"
8
+ require "active_support/core_ext/object/blank"
9
+ require "rack"
10
10
 
11
- require_relative 'clowk/version'
12
- require_relative 'clowk/configuration'
11
+ require_relative "clowk/version"
12
+ require_relative "clowk/configuration"
13
13
 
14
14
  module Clowk
15
15
  class Error < StandardError; end
@@ -24,6 +24,7 @@ module Clowk
24
24
 
25
25
  def configure
26
26
  yield(config)
27
+ config.validate!
27
28
  end
28
29
 
29
30
  def reset!
@@ -33,25 +34,25 @@ module Clowk
33
34
  end
34
35
  end
35
36
 
36
- require_relative 'clowk/current'
37
- require_relative 'clowk/http/response'
38
- require_relative 'clowk/http/logger_middleware'
39
- require_relative 'clowk/http/retry_middleware'
40
- require_relative 'clowk/http/timeout_middleware'
41
- require_relative 'clowk/http/client'
42
- require_relative 'clowk/sdk/resource'
43
- require_relative 'clowk/sdk/user'
44
- require_relative 'clowk/sdk/session'
45
- require_relative 'clowk/sdk/subdomain'
46
- require_relative 'clowk/sdk/token'
47
- require_relative 'clowk/sdk/session_config'
48
- require_relative 'clowk/sdk/client'
49
- require_relative 'clowk/subdomain'
50
- require_relative 'clowk/jwt_verifier'
51
- require_relative 'clowk/helpers/url_helpers'
52
- require_relative 'clowk/middleware/token_extractor'
53
- require_relative 'clowk/authenticable'
54
- require_relative 'clowk/controllers/base_controller'
55
- require_relative 'clowk/controllers/callbacks_controller'
56
- require_relative 'clowk/controllers/sessions_controller'
57
- require_relative 'clowk/engine'
37
+ require_relative "clowk/current"
38
+ require_relative "clowk/http/response"
39
+ require_relative "clowk/http/logger_middleware"
40
+ require_relative "clowk/http/retry_middleware"
41
+ require_relative "clowk/http/timeout_middleware"
42
+ require_relative "clowk/http/client"
43
+ require_relative "clowk/sdk/resource"
44
+ require_relative "clowk/sdk/user"
45
+ require_relative "clowk/sdk/session"
46
+ require_relative "clowk/sdk/subdomain"
47
+ require_relative "clowk/sdk/token"
48
+ require_relative "clowk/sdk/session_config"
49
+ require_relative "clowk/sdk/client"
50
+ require_relative "clowk/subdomain"
51
+ require_relative "clowk/jwt_verifier"
52
+ require_relative "clowk/helpers/url_helpers"
53
+ require_relative "clowk/middleware/token_extractor"
54
+ require_relative "clowk/authenticable"
55
+ require_relative "clowk/controllers/base_controller"
56
+ require_relative "clowk/controllers/callbacks_controller"
57
+ require_relative "clowk/controllers/sessions_controller"
58
+ require_relative "clowk/engine"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clowk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clowk
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-27 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -77,6 +77,20 @@ dependencies:
77
77
  - - "<"
78
78
  - !ruby/object:Gem::Version
79
79
  version: '4.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: standard
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '1.0'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '1.0'
80
94
  description: Clowk Authentication, JWT verification, and future API access
81
95
  email:
82
96
  - support@clowk.in
@@ -114,7 +128,7 @@ files:
114
128
  - lib/clowk/version.rb
115
129
  homepage: https://clowk.in
116
130
  licenses:
117
- - AGPL-3.0
131
+ - AGPL-3.0-only
118
132
  metadata:
119
133
  rubygems_mfa_required: 'true'
120
134
  rdoc_options: []
@@ -131,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
145
  - !ruby/object:Gem::Version
132
146
  version: '0'
133
147
  requirements: []
134
- rubygems_version: 3.6.2
148
+ rubygems_version: 3.6.9
135
149
  specification_version: 4
136
150
  summary: Rails SDK for Clowk authentication
137
151
  test_files: []