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.
@@ -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
- def authorize_url(state:, code_challenge:, redirect_uri:)
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: SecureRandom.urlsafe_base64(32)
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)
@@ -11,9 +11,29 @@ module Shakha
11
11
  github: "Shakha::Providers::GitHub"
12
12
  }.freeze
13
13
 
14
- def self.resolve(name)
15
- class_name = PROVIDER_MAP[name.to_sym] || raise(ConfigurationError, "Unknown provider: #{name}")
16
- class_name.constantize.new
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
@@ -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 :check_rate_limit_token, only: [:token]
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 check_rate_limit_token
19
- check_rate_limit("token", max: 10, period: 1.minute)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.3.0"
5
- end
4
+ VERSION = "0.9.0"
5
+ end
data/lib/shakha.rb CHANGED
@@ -24,4 +24,5 @@ module Shakha
24
24
  class ConfigurationError < StandardError; end
25
25
  class PKCEError < StandardError; end
26
26
  class OAuthError < StandardError; end
27
- end
27
+ class ProviderNotFound < StandardError; end
28
+ 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.3.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: '2.7'
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: '2.7'
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 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.
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
- - asrat@example.com
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/models/shakha/client.rb
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/initializer.rb.erb
95
- - lib/generators/shakha/templates/migration.rb.erb
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.dev
117
+ homepage: https://github.com/Asrat77/shakha
110
118
  licenses:
111
119
  - MIT
112
120
  metadata:
113
- homepage_uri: https://shakha.dev
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: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
142
+ summary: SPA-first OAuth session broker for Rails — one redirect, one token, done
131
143
  test_files: []
@@ -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