shakha 0.1.1 → 0.1.4
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 +84 -27
- data/app/controllers/shakha/application_controller.rb +2 -0
- data/app/controllers/shakha/auth_controller.rb +53 -7
- data/app/controllers/shakha/session_controller.rb +41 -1
- data/lib/shakha/auditable.rb +47 -0
- data/lib/shakha/config.rb +3 -1
- data/lib/shakha/config_validator.rb +26 -0
- data/lib/shakha/engine.rb +6 -0
- data/lib/shakha/error_handler.rb +3 -2
- data/lib/shakha/rate_limiter.rb +39 -0
- data/lib/shakha/version.rb +1 -1
- data/lib/shakha.rb +3 -0
- metadata +37 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b5eb8fae72d4a779316f8266fc29f1078bf5b31c11a5d90ae922f46d3e37928
|
|
4
|
+
data.tar.gz: 95bc2e261d8a08c818ad75b4beefc8e5a4fa97802ef5cf28afdfbe7a895cdcc1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ee17e65c726cb0564e720fb73648b74567fcfb1edae5befbd14a16942ca877d5c007cfc8b622fed8c949211de3bf5b792fdff1190482bfdd414351b451e6204
|
|
7
|
+
data.tar.gz: 2205e02b9c9ebde26def46da665011571a5071a50bd79eaf6bb2d7075aed353d2d3b52eff1d6986113bf612ce2a6e2244e200e53b4aadf2b4c7fba8020ed354b
|
data/README.md
CHANGED
|
@@ -1,22 +1,72 @@
|
|
|
1
1
|
# Shakha
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Headless Google OAuth broker for Rails — PKCE, pairwise subjects, zero JavaScript.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Same Google account, different IDs per application. Built DHH-style: database sessions, Turbo native, single "Continue with Google" button.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Installation
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
gem "shakha"
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Run the
|
|
13
|
+
Run the migration:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
rails generate
|
|
16
|
+
bin/rails generate migration CreateShakhaTables
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
21
|
+
def change
|
|
22
|
+
create_table :shakha_clients do |t|
|
|
23
|
+
t.string :name, null: false
|
|
24
|
+
t.string :origin, null: false
|
|
25
|
+
t.timestamps
|
|
26
|
+
t.index :origin, unique: true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
create_table :shakha_users do |t|
|
|
30
|
+
t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
|
|
31
|
+
t.string :pairwise_sub, null: false
|
|
32
|
+
t.string :email
|
|
33
|
+
t.string :name
|
|
34
|
+
t.string :picture
|
|
35
|
+
t.timestamps
|
|
36
|
+
t.index :pairwise_sub, unique: true
|
|
37
|
+
t.index :email
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
create_table :shakha_sessions do |t|
|
|
41
|
+
t.references :user, foreign_key: { to_table: :shakha_users }
|
|
42
|
+
t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
|
|
43
|
+
t.string :token, null: false
|
|
44
|
+
t.string :jti, null: false
|
|
45
|
+
t.timestamps
|
|
46
|
+
t.index :token, unique: true
|
|
47
|
+
t.index :jti, unique: true
|
|
48
|
+
t.index :created_at
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Create `config/initializers/shakha.rb`:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Shakha.setup do |config|
|
|
60
|
+
config.app_origin = ENV.fetch("SHAKHA_APP_ORIGIN", "http://localhost:3000")
|
|
61
|
+
config.service_url = ENV["SHAKHA_SERVICE_URL"] # omit for embedded mode
|
|
62
|
+
config.service_secret = ENV["SHAKHA_SERVICE_SECRET"]
|
|
63
|
+
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
64
|
+
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
65
|
+
config.session_lifetime = 30.days
|
|
66
|
+
end
|
|
17
67
|
```
|
|
18
68
|
|
|
19
|
-
|
|
69
|
+
Environment variables:
|
|
20
70
|
|
|
21
71
|
```bash
|
|
22
72
|
export SHAKHA_APP_ORIGIN="https://yourapp.com"
|
|
@@ -25,60 +75,67 @@ export GOOGLE_CLIENT_ID="your-google-client-id"
|
|
|
25
75
|
export GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
|
26
76
|
```
|
|
27
77
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
See `config/initializers/shakha.rb` for all options.
|
|
78
|
+
Google Cloud Console redirect URI: `https://yourapp.com/auth/shakha/callback`
|
|
31
79
|
|
|
32
80
|
## Usage
|
|
33
81
|
|
|
34
82
|
### Sign In
|
|
35
83
|
|
|
36
|
-
Redirect users to sign in:
|
|
37
|
-
|
|
38
84
|
```erb
|
|
39
85
|
<%= link_to "Sign in with Google", shakha.new_auth_path %>
|
|
40
86
|
```
|
|
41
87
|
|
|
42
|
-
###
|
|
43
|
-
|
|
44
|
-
In controllers:
|
|
88
|
+
### Protect Routes
|
|
45
89
|
|
|
46
90
|
```ruby
|
|
47
91
|
class ApplicationController < ActionController::Base
|
|
48
92
|
include Shakha::ControllerHelpers
|
|
93
|
+
before_action :authenticate!
|
|
49
94
|
end
|
|
50
95
|
```
|
|
51
96
|
|
|
97
|
+
### Current User
|
|
98
|
+
|
|
52
99
|
```ruby
|
|
53
100
|
current_user # Shakha::User or nil
|
|
54
|
-
current_session
|
|
55
|
-
signed_in?
|
|
56
|
-
authenticate!
|
|
101
|
+
current_session # Shakha::Session or nil
|
|
102
|
+
signed_in? # boolean
|
|
103
|
+
authenticate! # redirects to login if not signed in
|
|
57
104
|
```
|
|
58
105
|
|
|
59
|
-
###
|
|
106
|
+
### Sign Out
|
|
60
107
|
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
before_action :authenticate!
|
|
64
|
-
end
|
|
108
|
+
```erb
|
|
109
|
+
<%= link_to "Sign out", shakha.session_path, data: { turbo_method: :delete } %>
|
|
65
110
|
```
|
|
66
111
|
|
|
67
112
|
### JWT Verification (API Mode)
|
|
68
113
|
|
|
69
114
|
```ruby
|
|
70
115
|
payload = Shakha.verify_token(id_token)
|
|
71
|
-
user_id = payload[:
|
|
116
|
+
user_id = payload[:pairwise_sub]
|
|
72
117
|
```
|
|
73
118
|
|
|
74
119
|
## Architecture
|
|
75
120
|
|
|
76
121
|
- **PKCE** — S256 code challenges on every flow
|
|
77
|
-
- **Pairwise subjects** — domain-scoped user identifiers
|
|
78
|
-
- **ES256 JWTs** — signed with JWKS endpoint
|
|
122
|
+
- **Pairwise subjects** — domain-scoped user identifiers (HMAC-SHA256)
|
|
123
|
+
- **ES256 JWTs** — signed with JWKS endpoint at `.well-known/jwks.json`
|
|
124
|
+
- **OpenID Connect** — `.well-known/openid-configuration` endpoint
|
|
79
125
|
- **Database sessions** — DHH-style, no Redis
|
|
80
|
-
- **Turbo native** — zero
|
|
126
|
+
- **Turbo native** — zero JavaScript needed
|
|
127
|
+
- **Embedded or standalone** — runs as Rails engine or headless service
|
|
128
|
+
|
|
129
|
+
## Modes
|
|
130
|
+
|
|
131
|
+
### Embedded (default)
|
|
132
|
+
|
|
133
|
+
Mount in your Rails app. Routes served at `/auth/shakha`. Uses the app's own `shakha_clients` table with a single client auto-created on first request.
|
|
134
|
+
|
|
135
|
+
### Service (multi-tenant)
|
|
136
|
+
|
|
137
|
+
Set `SHAKHA_SERVICE_URL` and register each app's origin in `shakha_clients`. Each app gets different pairwise subjects for the same Google user.
|
|
81
138
|
|
|
82
139
|
## License
|
|
83
140
|
|
|
84
|
-
MIT
|
|
141
|
+
MIT
|
|
@@ -11,10 +11,11 @@ module Shakha
|
|
|
11
11
|
|
|
12
12
|
def new
|
|
13
13
|
@client = find_or_create_client
|
|
14
|
-
@return_to = params[:return_to]
|
|
14
|
+
@return_to = sanitize_return_to(params[:return_to])
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def authorize
|
|
18
|
+
params[:return_to] = sanitize_return_to(params[:return_to])
|
|
18
19
|
pkce = create_pkce_bundle
|
|
19
20
|
@client = find_or_create_client
|
|
20
21
|
|
|
@@ -27,7 +28,12 @@ module Shakha
|
|
|
27
28
|
pkce_result = verify_pkce!(params[:code], params[:state])
|
|
28
29
|
exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
|
|
29
30
|
rescue PKCEError, GoogleOAuthError => e
|
|
30
|
-
|
|
31
|
+
ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
|
|
32
|
+
reason: e.class.name,
|
|
33
|
+
ip: request.remote_ip
|
|
34
|
+
})
|
|
35
|
+
Rails.logger.warn("[Shakha] Auth error: #{e.class}: #{e.message}")
|
|
36
|
+
redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(user_facing_error(e))}"
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def token
|
|
@@ -54,13 +60,52 @@ module Shakha
|
|
|
54
60
|
|
|
55
61
|
private
|
|
56
62
|
|
|
63
|
+
def sanitize_return_to(raw)
|
|
64
|
+
return "/" if raw.blank?
|
|
65
|
+
|
|
66
|
+
uri = URI.parse(raw)
|
|
67
|
+
return "/" if uri.host.present? && ![app_origin_host, client_origin_host].include?(uri.host)
|
|
68
|
+
return "/" unless uri.path.present? && uri.path.start_with?("/")
|
|
69
|
+
|
|
70
|
+
uri.path
|
|
71
|
+
rescue URI::InvalidURIError
|
|
72
|
+
"/"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def app_origin_host
|
|
76
|
+
URI.parse(Shakha.config.app_origin).host
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def client_origin_host
|
|
80
|
+
URI.parse(Shakha.config.service_base_url).host
|
|
81
|
+
rescue URI::InvalidURIError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def user_facing_error(exception)
|
|
86
|
+
case exception
|
|
87
|
+
when PKCEError
|
|
88
|
+
"Authentication failed. Please try again."
|
|
89
|
+
when GoogleOAuthError
|
|
90
|
+
"Unable to sign in with Google. Please try again later."
|
|
91
|
+
else
|
|
92
|
+
"An unexpected error occurred. Please try again."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
57
96
|
def find_or_create_client
|
|
58
97
|
origin = request.origin || Shakha.config.app_origin
|
|
59
98
|
origin_uri = URI.parse(origin).origin
|
|
60
99
|
|
|
61
|
-
Shakha
|
|
62
|
-
|
|
100
|
+
if Shakha.config.embedded?
|
|
101
|
+
Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
|
|
102
|
+
client.name = URI.parse(origin).host
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
Shakha::Client.find_by!(origin: origin_uri)
|
|
63
106
|
end
|
|
107
|
+
rescue ActiveRecord::RecordNotFound
|
|
108
|
+
raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
|
|
64
109
|
end
|
|
65
110
|
|
|
66
111
|
def build_google_auth_url(pkce)
|
|
@@ -114,12 +159,13 @@ module Shakha
|
|
|
114
159
|
|
|
115
160
|
payload = decode_id_token(id_token)
|
|
116
161
|
google_sub = payload["sub"]
|
|
117
|
-
pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
|
|
118
162
|
|
|
119
163
|
client = find_or_create_client
|
|
164
|
+
pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
|
|
165
|
+
|
|
120
166
|
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
|
|
121
167
|
|
|
122
|
-
if
|
|
168
|
+
if payload["email"]
|
|
123
169
|
user.assign_attributes(
|
|
124
170
|
email: payload["email"],
|
|
125
171
|
name: payload["name"],
|
|
@@ -142,7 +188,7 @@ module Shakha
|
|
|
142
188
|
expires: Shakha.config.session_lifetime.from_now
|
|
143
189
|
}
|
|
144
190
|
|
|
145
|
-
redirect_to return_to
|
|
191
|
+
redirect_to sanitize_return_to(return_to)
|
|
146
192
|
end
|
|
147
193
|
|
|
148
194
|
def exchange_code_for_id_token(code, verifier)
|
|
@@ -4,6 +4,25 @@ module Shakha
|
|
|
4
4
|
class SessionController < ApplicationController
|
|
5
5
|
skip_before_action :verify_authenticity_token, only: [:check]
|
|
6
6
|
|
|
7
|
+
def index
|
|
8
|
+
return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
|
|
9
|
+
|
|
10
|
+
sessions = current_user.sessions.active.order(created_at: :desc)
|
|
11
|
+
|
|
12
|
+
render json: {
|
|
13
|
+
current_token: current_session.token,
|
|
14
|
+
sessions: sessions.map { |s|
|
|
15
|
+
{
|
|
16
|
+
id: s.id,
|
|
17
|
+
token: s.token,
|
|
18
|
+
created_at: s.created_at.iso8601,
|
|
19
|
+
expires_at: s.expires_at.iso8601,
|
|
20
|
+
current: s.token == current_session.token
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
7
26
|
def show
|
|
8
27
|
render json: {
|
|
9
28
|
user_id: current_user&.pairwise_sub,
|
|
@@ -27,7 +46,28 @@ module Shakha
|
|
|
27
46
|
def destroy
|
|
28
47
|
current_session&.destroy
|
|
29
48
|
cookies.delete(:shakha_session_token)
|
|
30
|
-
|
|
49
|
+
|
|
50
|
+
respond_to do |format|
|
|
51
|
+
format.html { redirect_to params[:return_to].presence || "/" }
|
|
52
|
+
format.json { render json: { status: "signed_out" } }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def revoke
|
|
57
|
+
return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
|
|
58
|
+
|
|
59
|
+
session = current_user.sessions.find(params[:id])
|
|
60
|
+
session.destroy
|
|
61
|
+
|
|
62
|
+
cookies.delete(:shakha_session_token) if session.token == current_session&.token
|
|
63
|
+
|
|
64
|
+
ActiveSupport::Notifications.instrument("shakha.session_revoked", {
|
|
65
|
+
session_id: session.id,
|
|
66
|
+
user_id: current_user.id,
|
|
67
|
+
ip: request.remote_ip
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
render json: { status: "revoked" }
|
|
31
71
|
end
|
|
32
72
|
end
|
|
33
73
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
module Auditable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_action :log_sign_in, only: [:callback]
|
|
9
|
+
after_action :log_sign_out, only: [:destroy]
|
|
10
|
+
after_action :log_token_exchange, only: [:token]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def log_sign_in
|
|
16
|
+
return unless response.successful? && @current_user
|
|
17
|
+
|
|
18
|
+
ActiveSupport::Notifications.instrument("shakha.sign_in", {
|
|
19
|
+
user_id: @current_user&.id,
|
|
20
|
+
pairwise_sub: @current_user&.pairwise_sub,
|
|
21
|
+
client_id: @current_client&.id,
|
|
22
|
+
ip: request.remote_ip,
|
|
23
|
+
user_agent: request.user_agent
|
|
24
|
+
})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def log_sign_out
|
|
28
|
+
return unless action_name == "destroy"
|
|
29
|
+
|
|
30
|
+
ActiveSupport::Notifications.instrument("shakha.sign_out", {
|
|
31
|
+
session_id: @current_session&.id,
|
|
32
|
+
user_id: @current_session&.user_id,
|
|
33
|
+
ip: request.remote_ip
|
|
34
|
+
})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_token_exchange
|
|
38
|
+
return unless action_name == "token"
|
|
39
|
+
|
|
40
|
+
ActiveSupport::Notifications.instrument("shakha.token_exchange", {
|
|
41
|
+
ip: request.remote_ip,
|
|
42
|
+
user_agent: request.user_agent,
|
|
43
|
+
success: response.successful?
|
|
44
|
+
})
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/shakha/config.rb
CHANGED
|
@@ -11,11 +11,13 @@ module Shakha
|
|
|
11
11
|
:session_lifetime,
|
|
12
12
|
:signing_key,
|
|
13
13
|
:verification_key,
|
|
14
|
-
:key_id
|
|
14
|
+
:key_id,
|
|
15
|
+
:rate_limiting_enabled
|
|
15
16
|
|
|
16
17
|
def initialize
|
|
17
18
|
@session_lifetime = 30.days
|
|
18
19
|
@issuer = "https://shakha.dev"
|
|
20
|
+
@rate_limiting_enabled = false
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def embedded?
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
module ConfigValidator
|
|
5
|
+
class << self
|
|
6
|
+
def validate!(config)
|
|
7
|
+
missing = []
|
|
8
|
+
missing << "SHAKHA_APP_ORIGIN" unless config.app_origin.present?
|
|
9
|
+
missing << "GOOGLE_CLIENT_ID" unless config.google_client_id.present?
|
|
10
|
+
missing << "GOOGLE_CLIENT_SECRET" unless config.google_client_secret.present?
|
|
11
|
+
missing << "SHAKHA_SERVICE_SECRET" unless config.service_secret.present?
|
|
12
|
+
|
|
13
|
+
unless missing.empty?
|
|
14
|
+
message = "Shakha: missing required configuration: #{missing.join(', ')}"
|
|
15
|
+
if Rails.env.production?
|
|
16
|
+
raise ConfigurationError, message
|
|
17
|
+
else
|
|
18
|
+
Rails.logger.warn(message)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/shakha/engine.rb
CHANGED
|
@@ -6,6 +6,10 @@ module Shakha
|
|
|
6
6
|
|
|
7
7
|
config.app_middleware.use Shakha::Middleware
|
|
8
8
|
|
|
9
|
+
config.after_initialize do
|
|
10
|
+
Shakha::ConfigValidator.validate!(Shakha.config)
|
|
11
|
+
end
|
|
12
|
+
|
|
9
13
|
# Engine routes - these should be relative paths
|
|
10
14
|
routes do
|
|
11
15
|
root to: "auth#new"
|
|
@@ -16,8 +20,10 @@ module Shakha
|
|
|
16
20
|
get "error" => "auth#error"
|
|
17
21
|
|
|
18
22
|
get "session" => "session#show"
|
|
23
|
+
get "sessions" => "session#index"
|
|
19
24
|
post "session/check" => "session#check"
|
|
20
25
|
delete "session" => "session#destroy"
|
|
26
|
+
delete "sessions/:id" => "session#revoke"
|
|
21
27
|
|
|
22
28
|
get ".well-known/jwks.json" => "jwks#show"
|
|
23
29
|
get ".well-known/openid-configuration" => "openid#configuration"
|
data/lib/shakha/error_handler.rb
CHANGED
|
@@ -24,11 +24,12 @@ module Shakha
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def bad_request(exception)
|
|
27
|
-
render json: { error:
|
|
27
|
+
render json: { error: "Bad request" }, status: :bad_request
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def bad_gateway(exception)
|
|
31
|
-
|
|
31
|
+
Rails.logger.error("[Shakha] Google OAuth error: #{exception.message}")
|
|
32
|
+
render json: { error: "Authentication service unavailable" }, status: :bad_gateway
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
module RateLimiter
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :check_rate_limit_authorize, only: [:authorize]
|
|
9
|
+
before_action :check_rate_limit_token, only: [:token]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def check_rate_limit_authorize
|
|
15
|
+
check_rate_limit("authorize", max: 20, period: 1.minute)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_rate_limit_token
|
|
19
|
+
check_rate_limit("token", max: 10, period: 1.minute)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_rate_limit(key, max:, period:)
|
|
23
|
+
return unless Shakha.config.rate_limiting_enabled?
|
|
24
|
+
|
|
25
|
+
cache_key = "shakha-rate:#{key}:#{request.remote_ip}"
|
|
26
|
+
|
|
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
|
|
32
|
+
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
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/shakha/version.rb
CHANGED
data/lib/shakha.rb
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "shakha/version"
|
|
4
4
|
require "shakha/config"
|
|
5
|
+
require "shakha/config_validator"
|
|
5
6
|
require "shakha/pairwise"
|
|
6
7
|
require "shakha/jwt_handler"
|
|
7
8
|
require "shakha/pkce"
|
|
9
|
+
require "shakha/rate_limiter"
|
|
10
|
+
require "shakha/auditable"
|
|
8
11
|
require "shakha/error_handler"
|
|
9
12
|
require "shakha/controller_helpers"
|
|
10
13
|
require "shakha/middleware"
|
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.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -30,6 +30,9 @@ dependencies:
|
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '7.1'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '10'
|
|
33
36
|
type: :runtime
|
|
34
37
|
prerelease: false
|
|
35
38
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -37,9 +40,36 @@ dependencies:
|
|
|
37
40
|
- - ">="
|
|
38
41
|
- !ruby/object:Gem::Version
|
|
39
42
|
version: '7.1'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '10'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: railties
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '7.1'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '10'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '7.1'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '10'
|
|
40
66
|
description: |
|
|
41
|
-
Shakha
|
|
42
|
-
|
|
67
|
+
Shakha is a headless authentication broker gem for Rails that handles Google OAuth 2.0
|
|
68
|
+
with PKCE security. It provides domain-scoped user identifiers via pairwise subjects,
|
|
69
|
+
ensuring the same Google account gets different IDs across different applications.
|
|
70
|
+
|
|
71
|
+
Built DHH-style: database sessions (no Redis), Turbo native (zero JS), and a single
|
|
72
|
+
"Continue with Google" button. Works as an embedded Rails engine or standalone service.
|
|
43
73
|
email:
|
|
44
74
|
- asrat@example.com
|
|
45
75
|
executables: []
|
|
@@ -66,7 +96,9 @@ files:
|
|
|
66
96
|
- lib/generators/shakha/templates/initializer.rb.erb
|
|
67
97
|
- lib/generators/shakha/templates/migration.rb.erb
|
|
68
98
|
- lib/shakha.rb
|
|
99
|
+
- lib/shakha/auditable.rb
|
|
69
100
|
- lib/shakha/config.rb
|
|
101
|
+
- lib/shakha/config_validator.rb
|
|
70
102
|
- lib/shakha/controller_helpers.rb
|
|
71
103
|
- lib/shakha/engine.rb
|
|
72
104
|
- lib/shakha/error_handler.rb
|
|
@@ -74,6 +106,7 @@ files:
|
|
|
74
106
|
- lib/shakha/middleware.rb
|
|
75
107
|
- lib/shakha/pairwise.rb
|
|
76
108
|
- lib/shakha/pkce.rb
|
|
109
|
+
- lib/shakha/rate_limiter.rb
|
|
77
110
|
- lib/shakha/version.rb
|
|
78
111
|
homepage: https://shakha.dev
|
|
79
112
|
licenses:
|
|
@@ -96,5 +129,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
96
129
|
requirements: []
|
|
97
130
|
rubygems_version: 3.6.9
|
|
98
131
|
specification_version: 4
|
|
99
|
-
summary:
|
|
132
|
+
summary: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
|
|
100
133
|
test_files: []
|