shakha 0.3.0 → 0.9.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/CHANGELOG.md +128 -0
- data/README.md +174 -96
- data/SECURITY.md +56 -0
- data/app/controllers/shakha/application_controller.rb +2 -3
- data/app/controllers/shakha/auth_controller.rb +52 -32
- data/app/controllers/shakha/session_controller.rb +13 -0
- data/app/controllers/shakha/sessions_controller.rb +33 -0
- data/app/models/shakha/session.rb +31 -2
- data/app/models/shakha/user.rb +1 -3
- data/app/views/shakha/auth/new.html.erb +4 -4
- data/app/views/shakha/layouts/shakha.html.erb +1 -1
- data/lib/generators/shakha/install/install_generator.rb +103 -0
- data/lib/generators/shakha/install/templates/create_shakha_tables.rb.erb +27 -0
- data/lib/generators/shakha/install/templates/shakha.rb.erb +42 -0
- data/lib/shakha/config.rb +6 -2
- data/lib/shakha/config_validator.rb +11 -2
- data/lib/shakha/controller_helpers.rb +6 -4
- data/lib/shakha/engine.rb +12 -7
- data/lib/shakha/error_handler.rb +6 -1
- data/lib/shakha/pkce.rb +5 -4
- data/lib/shakha/providers/base.rb +2 -2
- data/lib/shakha/providers/github.rb +4 -2
- data/lib/shakha/providers/google.rb +26 -3
- data/lib/shakha/providers.rb +23 -3
- data/lib/shakha/rate_limiter.rb +5 -5
- data/lib/shakha/version.rb +2 -2
- data/lib/shakha.rb +2 -1
- metadata +31 -19
- data/app/models/shakha/client.rb +0 -16
- data/lib/generators/shakha/install_generator.rb +0 -25
- data/lib/generators/shakha/templates/initializer.rb.erb +0 -29
- data/lib/generators/shakha/templates/migration.rb.erb +0 -45
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Shakha
|
|
4
4
|
module Providers
|
|
5
5
|
class Base
|
|
6
|
-
def authorize_url(state:, code_challenge:, redirect_uri:)
|
|
6
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
7
7
|
raise NotImplementedError
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ module Shakha
|
|
|
11
11
|
raise NotImplementedError
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def identity_from_response(token_response)
|
|
14
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
15
15
|
raise NotImplementedError
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -14,7 +14,9 @@ module Shakha
|
|
|
14
14
|
:github
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# GitHub OAuth has no nonce concept; the keyword is accepted for a
|
|
18
|
+
# uniform provider interface and ignored.
|
|
19
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
18
20
|
params = {
|
|
19
21
|
client_id: Shakha.config.github_client_id,
|
|
20
22
|
redirect_uri: redirect_uri,
|
|
@@ -36,7 +38,7 @@ module Shakha
|
|
|
36
38
|
JSON.parse(response.body)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
def identity_from_response(token_response)
|
|
41
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
40
42
|
access_token = token_response["access_token"]
|
|
41
43
|
raise OAuthError, "No access_token received" unless access_token
|
|
42
44
|
|
|
@@ -13,7 +13,7 @@ module Shakha
|
|
|
13
13
|
:google
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def authorize_url(state:, code_challenge:, redirect_uri:)
|
|
16
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
17
17
|
params = {
|
|
18
18
|
client_id: Shakha.config.google_client_id,
|
|
19
19
|
redirect_uri: redirect_uri,
|
|
@@ -24,7 +24,7 @@ module Shakha
|
|
|
24
24
|
state: state,
|
|
25
25
|
access_type: "offline",
|
|
26
26
|
prompt: "consent",
|
|
27
|
-
nonce:
|
|
27
|
+
nonce: nonce
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
"#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
|
|
@@ -43,11 +43,15 @@ module Shakha
|
|
|
43
43
|
JSON.parse(response.body)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def identity_from_response(token_response)
|
|
46
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
47
47
|
id_token = token_response["id_token"]
|
|
48
48
|
raise OAuthError, "No id_token received" unless id_token
|
|
49
49
|
|
|
50
|
+
# Signature is not re-verified: the token comes directly from Google's
|
|
51
|
+
# token endpoint over a TLS connection we initiated, so its provenance
|
|
52
|
+
# is the transport. The claims below still need checking.
|
|
50
53
|
payload = JWT.decode(id_token, nil, false)[0]
|
|
54
|
+
verify_claims!(payload, expected_nonce)
|
|
51
55
|
|
|
52
56
|
{
|
|
53
57
|
provider: :google,
|
|
@@ -64,6 +68,25 @@ module Shakha
|
|
|
64
68
|
|
|
65
69
|
private
|
|
66
70
|
|
|
71
|
+
VALID_ISSUERS = [ "https://accounts.google.com", "accounts.google.com" ].freeze
|
|
72
|
+
|
|
73
|
+
def verify_claims!(payload, expected_nonce)
|
|
74
|
+
unless VALID_ISSUERS.include?(payload["iss"])
|
|
75
|
+
raise OAuthError, "ID token issuer mismatch"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless payload["aud"] == Shakha.config.google_client_id
|
|
79
|
+
raise OAuthError, "ID token audience mismatch"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise OAuthError, "ID token expired" if payload["exp"].to_i <= Time.now.to_i
|
|
83
|
+
|
|
84
|
+
if expected_nonce.present? &&
|
|
85
|
+
!ActiveSupport::SecurityUtils.secure_compare(payload["nonce"].to_s, expected_nonce)
|
|
86
|
+
raise OAuthError, "ID token nonce mismatch"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
def http_post(url, body)
|
|
68
91
|
uri = URI.parse(url)
|
|
69
92
|
http = Net::HTTP.new(uri.host, uri.port)
|
data/lib/shakha/providers.rb
CHANGED
|
@@ -11,9 +11,29 @@ module Shakha
|
|
|
11
11
|
github: "Shakha::Providers::GitHub"
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
@registry = PROVIDER_MAP.dup
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Registers a provider under +name+. +klass+ is a Providers::Base
|
|
18
|
+
# subclass or its name as a String (a String survives code reloading in
|
|
19
|
+
# development). Registering an existing name replaces it.
|
|
20
|
+
def register(name, klass)
|
|
21
|
+
@registry[name.to_sym] = klass
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def registered
|
|
25
|
+
@registry.keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve(name)
|
|
29
|
+
klass = @registry[name.to_sym] || raise(ConfigurationError, "Unknown provider: #{name}")
|
|
30
|
+
klass = klass.constantize if klass.is_a?(String)
|
|
31
|
+
klass.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset!
|
|
35
|
+
@registry = PROVIDER_MAP.dup
|
|
36
|
+
end
|
|
17
37
|
end
|
|
18
38
|
end
|
|
19
39
|
end
|
data/lib/shakha/rate_limiter.rb
CHANGED
|
@@ -5,8 +5,8 @@ module Shakha
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
|
-
before_action :check_rate_limit_authorize, only: [:authorize]
|
|
9
|
-
before_action :
|
|
8
|
+
before_action :check_rate_limit_authorize, only: [ :authorize ]
|
|
9
|
+
before_action :check_rate_limit_callback, only: [ :callback ]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
private
|
|
@@ -15,15 +15,15 @@ module Shakha
|
|
|
15
15
|
check_rate_limit("authorize", max: 20, period: 1.minute)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
19
|
-
check_rate_limit("
|
|
18
|
+
def check_rate_limit_callback
|
|
19
|
+
check_rate_limit("callback", max: 10, period: 1.minute)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def check_rate_limit(key, max:, period:)
|
|
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
|
+
count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds) || 1
|
|
27
27
|
|
|
28
28
|
if count > max
|
|
29
29
|
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
|
data/lib/shakha/version.rb
CHANGED
data/lib/shakha.rb
CHANGED
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: jwt
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.10.3
|
|
19
|
+
- - "<"
|
|
17
20
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
21
|
+
version: '3'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 2.10.3
|
|
29
|
+
- - "<"
|
|
24
30
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
31
|
+
version: '3'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: activesupport
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -64,25 +70,27 @@ dependencies:
|
|
|
64
70
|
- !ruby/object:Gem::Version
|
|
65
71
|
version: '10'
|
|
66
72
|
description: |
|
|
67
|
-
Shakha is a headless
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
Shakha is a headless OAuth broker engine for Rails APIs and monoliths.
|
|
74
|
+
Your frontend does a single redirect; Shakha runs the OAuth dance
|
|
75
|
+
(PKCE, state) against Google or GitHub, stores a revocable
|
|
76
|
+
database-backed session, and redirects back with a session token usable
|
|
77
|
+
via encrypted cookie or Authorization: Bearer. No JWTs issued, no Redis,
|
|
78
|
+
no frontend SDK. Pre-1.0: APIs and security posture still evolving.
|
|
73
79
|
email:
|
|
74
|
-
-
|
|
80
|
+
- asratextras77@gmail.com
|
|
75
81
|
executables: []
|
|
76
82
|
extensions: []
|
|
77
83
|
extra_rdoc_files: []
|
|
78
84
|
files:
|
|
85
|
+
- CHANGELOG.md
|
|
79
86
|
- LICENSE.txt
|
|
80
87
|
- README.md
|
|
88
|
+
- SECURITY.md
|
|
81
89
|
- app/assets/stylesheets/shakha.css
|
|
82
90
|
- app/controllers/shakha/application_controller.rb
|
|
83
91
|
- app/controllers/shakha/auth_controller.rb
|
|
84
92
|
- app/controllers/shakha/session_controller.rb
|
|
85
|
-
- app/
|
|
93
|
+
- app/controllers/shakha/sessions_controller.rb
|
|
86
94
|
- app/models/shakha/session.rb
|
|
87
95
|
- app/models/shakha/user.rb
|
|
88
96
|
- app/views/shakha/auth/callback.html.erb
|
|
@@ -90,9 +98,9 @@ files:
|
|
|
90
98
|
- app/views/shakha/auth/new.html.erb
|
|
91
99
|
- app/views/shakha/errors/show.html.erb
|
|
92
100
|
- app/views/shakha/layouts/shakha.html.erb
|
|
93
|
-
- lib/generators/shakha/install_generator.rb
|
|
94
|
-
- lib/generators/shakha/templates/
|
|
95
|
-
- lib/generators/shakha/templates/
|
|
101
|
+
- lib/generators/shakha/install/install_generator.rb
|
|
102
|
+
- lib/generators/shakha/install/templates/create_shakha_tables.rb.erb
|
|
103
|
+
- lib/generators/shakha/install/templates/shakha.rb.erb
|
|
96
104
|
- lib/shakha.rb
|
|
97
105
|
- lib/shakha/config.rb
|
|
98
106
|
- lib/shakha/config_validator.rb
|
|
@@ -106,11 +114,15 @@ files:
|
|
|
106
114
|
- lib/shakha/providers/google.rb
|
|
107
115
|
- lib/shakha/rate_limiter.rb
|
|
108
116
|
- lib/shakha/version.rb
|
|
109
|
-
homepage: https://shakha
|
|
117
|
+
homepage: https://github.com/Asrat77/shakha
|
|
110
118
|
licenses:
|
|
111
119
|
- MIT
|
|
112
120
|
metadata:
|
|
113
|
-
homepage_uri: https://shakha
|
|
121
|
+
homepage_uri: https://github.com/Asrat77/shakha
|
|
122
|
+
source_code_uri: https://github.com/Asrat77/shakha
|
|
123
|
+
changelog_uri: https://github.com/Asrat77/shakha/blob/master/CHANGELOG.md
|
|
124
|
+
bug_tracker_uri: https://github.com/Asrat77/shakha/issues
|
|
125
|
+
rubygems_mfa_required: 'true'
|
|
114
126
|
rdoc_options: []
|
|
115
127
|
require_paths:
|
|
116
128
|
- lib
|
|
@@ -127,5 +139,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
127
139
|
requirements: []
|
|
128
140
|
rubygems_version: 3.6.9
|
|
129
141
|
specification_version: 4
|
|
130
|
-
summary:
|
|
142
|
+
summary: SPA-first OAuth session broker for Rails — one redirect, one token, done
|
|
131
143
|
test_files: []
|
data/app/models/shakha/client.rb
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Shakha
|
|
4
|
-
class Client < ::ApplicationRecord
|
|
5
|
-
self.table_name = "shakha_clients"
|
|
6
|
-
|
|
7
|
-
has_many :sessions, class_name: "Shakha::Session", dependent: :restrict_with_error
|
|
8
|
-
has_many :users, class_name: "Shakha::User", dependent: :nullify
|
|
9
|
-
|
|
10
|
-
validates :origin, presence: true, uniqueness: true
|
|
11
|
-
|
|
12
|
-
def self.find_by_origin!(origin)
|
|
13
|
-
find_by!(origin: URI.parse(origin).origin)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Shakha
|
|
4
|
-
class InstallGenerator < Rails::Generators::Base
|
|
5
|
-
source_root File.expand_path("templates", __dir__)
|
|
6
|
-
|
|
7
|
-
class_option :skip_migration, type: :boolean, default: false, desc: "Skip migration generation"
|
|
8
|
-
|
|
9
|
-
def copy_initializer
|
|
10
|
-
template "initializer.rb.erb", "config/initializers/shakha.rb"
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def create_migration
|
|
14
|
-
return if options[:skip_migration]
|
|
15
|
-
|
|
16
|
-
sleep 1
|
|
17
|
-
migration_number = Time.now.strftime("%Y%m%d%H%M%S")
|
|
18
|
-
template "migration.rb.erb", "db/migrate/#{migration_number}_create_shakha_tables.rb"
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def add_routes
|
|
22
|
-
route 'mount Shakha::Engine => "/auth/shakha", as: :shakha'
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Shakha Auth Configuration
|
|
4
|
-
#
|
|
5
|
-
# Set these environment variables:
|
|
6
|
-
# SHAKHA_SERVICE_SECRET - Secret key for pairwise subject derivation (HMAC-SHA256)
|
|
7
|
-
# GOOGLE_CLIENT_ID - Google OAuth client ID
|
|
8
|
-
# GOOGLE_CLIENT_SECRET - Google OAuth client secret
|
|
9
|
-
# SHAKHA_APP_ORIGIN - Your application's origin (e.g., http://localhost:3000)
|
|
10
|
-
# SHAKHA_SERVICE_URL - Optional: URL of standalone Shakha service (omit for embedded mode)
|
|
11
|
-
#
|
|
12
|
-
# For ES256 JWT signing, set either:
|
|
13
|
-
# SHAKHA_SIGNING_KEY - EC P-256 private key in PEM or base64 format
|
|
14
|
-
# SHAKHA_KEY_ID - Key ID for JWKS (optional, defaults to "default")
|
|
15
|
-
|
|
16
|
-
Shakha.setup do |config|
|
|
17
|
-
config.app_origin = ENV.fetch("SHAKHA_APP_ORIGIN", "http://localhost:3000")
|
|
18
|
-
config.service_url = ENV["SHAKHA_SERVICE_URL"]
|
|
19
|
-
config.service_secret = ENV["SHAKHA_SERVICE_SECRET"]
|
|
20
|
-
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
21
|
-
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
22
|
-
config.issuer = ENV.fetch("SHAKHA_ISSUER", "https://shakha.dev")
|
|
23
|
-
config.session_lifetime = 30.days
|
|
24
|
-
|
|
25
|
-
# JWT signing keys (required for service mode)
|
|
26
|
-
config.signing_key = ENV["SHAKHA_SIGNING_KEY"]
|
|
27
|
-
config.verification_key = ENV["SHAKHA_VERIFICATION_KEY"]
|
|
28
|
-
config.key_id = ENV["SHAKHA_KEY_ID"] || "default"
|
|
29
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
4
|
-
def change
|
|
5
|
-
create_table :shakha_clients do |t|
|
|
6
|
-
t.string :name, null: false
|
|
7
|
-
t.string :origin, null: false
|
|
8
|
-
t.string :client_id
|
|
9
|
-
t.json :metadata, default: {}
|
|
10
|
-
|
|
11
|
-
t.timestamps
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
add_index :shakha_clients, :origin, unique: true
|
|
15
|
-
add_index :shakha_clients, :client_id, unique: true
|
|
16
|
-
|
|
17
|
-
create_table :shakha_users do |t|
|
|
18
|
-
t.string :pairwise_sub, null: false
|
|
19
|
-
t.string :email
|
|
20
|
-
t.string :name
|
|
21
|
-
t.string :picture
|
|
22
|
-
t.references :client, null: false
|
|
23
|
-
|
|
24
|
-
t.timestamps
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
add_index :shakha_users, :pairwise_sub, unique: true
|
|
28
|
-
add_index :shakha_users, :email
|
|
29
|
-
|
|
30
|
-
create_table :shakha_sessions do |t|
|
|
31
|
-
t.string :token, null: false
|
|
32
|
-
t.string :jti, null: false
|
|
33
|
-
t.references :user, null: false
|
|
34
|
-
t.references :client, null: false
|
|
35
|
-
t.string :ip_address
|
|
36
|
-
t.string :user_agent
|
|
37
|
-
|
|
38
|
-
t.timestamps
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
add_index :shakha_sessions, :token, unique: true
|
|
42
|
-
add_index :shakha_sessions, :jti, unique: true
|
|
43
|
-
add_index :shakha_sessions, :created_at
|
|
44
|
-
end
|
|
45
|
-
end
|