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 +4 -4
- data/README.md +2 -0
- data/app/controllers/shakha/auth_controller.rb +35 -7
- data/lib/shakha/config.rb +2 -1
- data/lib/shakha/jwt_handler.rb +1 -1
- data/lib/shakha/pkce.rb +12 -18
- data/lib/shakha/rate_limiter.rb +2 -8
- data/lib/shakha/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 366829b83286435b8a3ff8a9b74cdfe319e072768efabbbfa0777d3ce7a9bb6b
|
|
4
|
+
data.tar.gz: 5aafc4d8d1db8bf6287c40ffea5dbb614903e288ddfeb35ae4d1c1e0b0ecff34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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[:
|
|
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
|
-
|
|
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
|
-
|
|
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
data/lib/shakha/jwt_handler.rb
CHANGED
|
@@ -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
|
|
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!(
|
|
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
|
|
70
|
+
raise PKCEError, "State mismatch" unless ActiveSupport::SecurityUtils.secure_compare(stored_state.to_s, state_param.to_s)
|
|
70
71
|
|
|
71
|
-
|
|
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
|
data/lib/shakha/rate_limiter.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/shakha/version.rb
CHANGED