shakha 0.1.7 → 0.2.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: 97e30df4b475b7c45fa0d75ef31ebe049760c19f6a65df602996b6aa2edd3ebe
4
- data.tar.gz: 5e1b4b6cdc4897e74e289411fae040a9ba31e8aedb800460e776011ee4fbfac1
3
+ metadata.gz: 366829b83286435b8a3ff8a9b74cdfe319e072768efabbbfa0777d3ce7a9bb6b
4
+ data.tar.gz: 5aafc4d8d1db8bf6287c40ffea5dbb614903e288ddfeb35ae4d1c1e0b0ecff34
5
5
  SHA512:
6
- metadata.gz: 1569eafa9f6f7054dd8457301815f6676cb29ba0b3658e5c00edb06896ed4cb700f4e6be3f2daaedc582519d9af12603fc08229170df1e162cd255f60b8bf3d4
7
- data.tar.gz: 2e92274af426cb8eb2ba1ed6279a6ebc89cee3c67d4c3085ad623015df5e8d2ed0756a023a74cb396a133fd4bbcd6ea2ed8058ba94132d0439078293d9451c61
6
+ metadata.gz: 6a7982ee421c97e9f66eb5eceb29dddd2159806d37d37bb759e84c80686390f395703adfd8b881c7a0d5eafa3c5e2bc969844ad6375ce4a04572b5e4758b5f75
7
+ data.tar.gz: f41c800d89c409fbcea81ab122d7f9e59028777b15643ec006ff0b6e0cddb7adcc621c463cdc538b088e171f9f4eb460cf5197b48b361700a041373c5899b2f7
data/README.md CHANGED
@@ -42,6 +42,8 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
42
42
  t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
43
43
  t.string :token, null: false
44
44
  t.string :jti, null: false
45
+ t.string :ip_address
46
+ t.string :user_agent
45
47
  t.timestamps
46
48
  t.index :token, unique: true
47
49
  t.index :jti, unique: true
@@ -26,8 +26,8 @@ module Shakha
26
26
  end
27
27
 
28
28
  def callback
29
- pkce_result = verify_pkce!(params[:code], params[:state])
30
- exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
29
+ pkce_result = verify_pkce!(params[:state])
30
+ exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to], pkce_result[:nonce])
31
31
  rescue PKCEError, GoogleOAuthError => e
32
32
  ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
33
33
  reason: e.class.name,
@@ -65,14 +65,25 @@ module Shakha
65
65
  return "/" if raw.blank?
66
66
 
67
67
  uri = URI.parse(raw)
68
- return "/" if uri.host.present? && ![app_origin_host, client_origin_host].include?(uri.host)
68
+ app_host = URI.parse(Shakha.config.app_origin).host
69
+
70
+ # Must have a path
69
71
  return "/" unless uri.path.present? && uri.path.start_with?("/")
70
72
 
71
- uri.path
73
+ # If external host, must be in allowed origins
74
+ if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
75
+ return "/"
76
+ end
77
+
78
+ raw
72
79
  rescue URI::InvalidURIError
73
80
  "/"
74
81
  end
75
82
 
83
+ def allowed_origin?(origin)
84
+ Shakha.config.allowed_redirect_origins&.include?(origin) || false
85
+ end
86
+
76
87
  def app_origin_host
77
88
  URI.parse(Shakha.config.app_origin).host
78
89
  end
@@ -125,6 +136,7 @@ module Shakha
125
136
  code_challenge: pkce[:challenge],
126
137
  code_challenge_method: "S256",
127
138
  state: pkce[:state],
139
+ nonce: pkce[:nonce],
128
140
  access_type: "offline",
129
141
  prompt: "consent"
130
142
  }
@@ -134,7 +146,7 @@ module Shakha
134
146
  end.to_s
135
147
  end
136
148
 
137
- def exchange_code_for_tokens(code, verifier, return_to = "/")
149
+ def exchange_code_for_tokens(code, verifier, return_to = "/", expected_nonce = nil)
138
150
  client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
139
151
  client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
140
152
  base_url = Shakha.config.service_base_url || "http://localhost:3000"
@@ -161,6 +173,11 @@ module Shakha
161
173
  payload = decode_id_token(id_token)
162
174
  google_sub = payload["sub"]
163
175
 
176
+ # Verify nonce (OIDC replay protection)
177
+ if expected_nonce && payload["nonce"] != expected_nonce
178
+ raise GoogleOAuthError, "Nonce mismatch"
179
+ end
180
+
164
181
  client = find_or_create_client
165
182
  pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
166
183
 
@@ -178,7 +195,6 @@ module Shakha
178
195
  session_record = Shakha::Session.create!(
179
196
  user: user,
180
197
  client: client,
181
- jti: SecureRandom.uuid,
182
198
  ip_address: request.remote_ip,
183
199
  user_agent: request.user_agent
184
200
  )
@@ -227,12 +243,24 @@ module Shakha
227
243
  uri = URI.parse(url)
228
244
  http = Net::HTTP.new(uri.host, uri.port)
229
245
  http.use_ssl = uri.scheme == "https"
246
+ http.open_timeout = 5
247
+ http.read_timeout = 10
230
248
 
231
249
  request = Net::HTTP::Post.new(uri.request_uri)
232
250
  request["Content-Type"] = "application/x-www-form-urlencoded"
233
251
  request.body = URI.encode_www_form(body)
234
252
 
235
- http.request(request)
253
+ response = http.request(request)
254
+
255
+ unless response.is_a?(Net::HTTPSuccess)
256
+ Rails.logger.error("[Shakha] Google API error: HTTP #{response.code} — #{response.body.truncate(500)}")
257
+ raise GoogleOAuthError, "Google returned HTTP #{response.code}"
258
+ end
259
+
260
+ response
261
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
262
+ Rails.logger.error("[Shakha] Network error contacting Google: #{e.message}")
263
+ raise GoogleOAuthError, "Unable to reach Google authentication service"
236
264
  end
237
265
  end
238
266
  end
data/lib/shakha/config.rb CHANGED
@@ -12,7 +12,8 @@ module Shakha
12
12
  :signing_key,
13
13
  :verification_key,
14
14
  :key_id,
15
- :rate_limiting_enabled
15
+ :rate_limiting_enabled,
16
+ :allowed_redirect_origins
16
17
 
17
18
  def initialize
18
19
  @session_lifetime = 30.days
@@ -89,7 +89,7 @@ module Shakha
89
89
  return signing_key&.public_key if signing_key
90
90
 
91
91
  public_material = Shakha.config.verification_key
92
- return nil unless public_material
92
+ return nil unless public_materiall
93
93
 
94
94
  if public_material.start_with?("-----BEGIN")
95
95
  OpenSSL::PKey::EC.new(public_material)
data/lib/shakha/pkce.rb CHANGED
@@ -14,9 +14,7 @@ module Shakha
14
14
 
15
15
  class << self
16
16
  def generate_code_verifier
17
- SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH)
18
- .tr("-_", "+/")
19
- .slice(0, CODE_VERIFIER_LENGTH)
17
+ SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH, padding: false)
20
18
  end
21
19
 
22
20
  def generate_code_challenge(verifier)
@@ -33,14 +31,16 @@ module Shakha
33
31
  verifier = PKCEMixin.generate_code_verifier
34
32
  challenge = PKCEMixin.generate_code_challenge(verifier)
35
33
  state = SecureRandom.urlsafe_base64(32)
34
+ nonce = SecureRandom.urlsafe_base64(32)
36
35
  return_to = params[:return_to] || "/"
37
36
 
38
37
  pkce_record = {
39
38
  verifier: verifier,
40
- return_to: return_to
39
+ return_to: return_to,
40
+ nonce: nonce
41
41
  }
42
42
 
43
- cookies[PKCE_COOKIE_NAME] = {
43
+ cookies.encrypted[PKCE_COOKIE_NAME] = {
44
44
  value: pkce_record.merge(state: state).to_json,
45
45
  httponly: true,
46
46
  secure: Rails.env.production?,
@@ -48,11 +48,11 @@ module Shakha
48
48
  expires: Time.now.utc + PKCE_COOKIE_EXPIRY_SECONDS
49
49
  }
50
50
 
51
- { challenge: challenge, state: state }
51
+ { challenge: challenge, state: state, nonce: nonce }
52
52
  end
53
53
 
54
- def verify_pkce!(code_verifier, state_param)
55
- pkce_json = cookies[PKCE_COOKIE_NAME]
54
+ def verify_pkce!(state_param)
55
+ pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
56
56
 
57
57
  raise PKCEError, "No PKCE session found" unless pkce_json
58
58
 
@@ -63,23 +63,17 @@ module Shakha
63
63
  stored_state = pkce_data[:state]
64
64
  stored_verifier = pkce_data[:verifier]
65
65
  stored_return_to = pkce_data[:return_to]
66
+ stored_nonce = pkce_data[:nonce]
66
67
 
67
68
  cookies.delete(PKCE_COOKIE_NAME)
68
69
 
69
- raise PKCEError, "State mismatch" unless stored_state == state_param
70
+ raise PKCEError, "State mismatch" unless ActiveSupport::SecurityUtils.secure_compare(stored_state.to_s, state_param.to_s)
70
71
 
71
- computed = PKCEMixin.generate_code_challenge(code_verifier)
72
- code_challenge = params[:code_challenge]
73
-
74
- if code_challenge.present?
75
- raise PKCEError, "Invalid code verifier" unless computed == code_challenge
76
- end
77
-
78
- { verifier: stored_verifier, return_to: stored_return_to }
72
+ { verifier: stored_verifier, return_to: stored_return_to, nonce: stored_nonce }
79
73
  end
80
74
 
81
75
  def pkce_state
82
- pkce_json = cookies[PKCE_COOKIE_NAME]
76
+ pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
83
77
  return nil unless pkce_json
84
78
 
85
79
  JSON.parse(pkce_json).with_indifferent_access
@@ -23,16 +23,10 @@ module Shakha
23
23
  return unless Shakha.config.rate_limiting_enabled
24
24
 
25
25
  cache_key = "shakha-rate:#{key}:#{request.remote_ip}"
26
+ count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds)
26
27
 
27
- count = Rails.cache.read(cache_key).to_i + 1
28
-
29
- if count == 1
30
- Rails.cache.write(cache_key, count, expires_in: period.seconds)
31
- elsif count > max
28
+ if count > max
32
29
  render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
33
- return
34
- else
35
- Rails.cache.write(cache_key, count, expires_in: period.seconds)
36
30
  end
37
31
  end
38
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.1.7"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shakha
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat