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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82e8c2eda1b9b817ce681d22f4e5a331d15e0c59588c972067d1fa432db03b62
4
- data.tar.gz: 979e1a3513ac57e1b1d6a81fffd954c1fb911fbb8d0cca729b65ce161bbe9f44
3
+ metadata.gz: 2b5eb8fae72d4a779316f8266fc29f1078bf5b31c11a5d90ae922f46d3e37928
4
+ data.tar.gz: 95bc2e261d8a08c818ad75b4beefc8e5a4fa97802ef5cf28afdfbe7a895cdcc1
5
5
  SHA512:
6
- metadata.gz: 80529b985eb5d490eada76a0829a9aa3311ec30af375c83b4f33ef0cb5e2399e4b1f75242bb8d5ee843e8b49d583da36ac84f53d0953b33d96868bf80dd9bb33
7
- data.tar.gz: 15777157e9a5fb3746019943f760b57ef92a9b10680056d0178f65d42392ed0391da8f1c3bf72c90e5c32d2c6110dac3420a8e1062cc0cf3d999b50309888c3d
6
+ metadata.gz: 0ee17e65c726cb0564e720fb73648b74567fcfb1edae5befbd14a16942ca877d5c007cfc8b622fed8c949211de3bf5b792fdff1190482bfdd414351b451e6204
7
+ data.tar.gz: 2205e02b9c9ebde26def46da665011571a5071a50bd79eaf6bb2d7075aed353d2d3b52eff1d6986113bf612ce2a6e2244e200e53b4aadf2b4c7fba8020ed354b
data/README.md CHANGED
@@ -1,22 +1,72 @@
1
1
  # Shakha
2
2
 
3
- Minimal auth broker for Google OAuth with PKCE and pairwise subjects.
3
+ Headless Google OAuth broker for Rails PKCE, pairwise subjects, zero JavaScript.
4
4
 
5
- ## Installation
5
+ Same Google account, different IDs per application. Built DHH-style: database sessions, Turbo native, single "Continue with Google" button.
6
6
 
7
- Add to your Gemfile:
7
+ ## Installation
8
8
 
9
9
  ```ruby
10
10
  gem "shakha"
11
11
  ```
12
12
 
13
- Run the installer:
13
+ Run the migration:
14
14
 
15
15
  ```bash
16
- rails generate shakha:install
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
- Set environment variables:
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
- ## Configuration
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
- ### Current User
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 # Shakha::Session or nil
55
- signed_in? # boolean
56
- authenticate! # redirect to login if not signed in
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
- ### Protect Routes
106
+ ### Sign Out
60
107
 
61
- ```ruby
62
- class PostsController < ApplicationController
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[:sub]
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 JS needed
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
@@ -4,6 +4,8 @@ module Shakha
4
4
  class ApplicationController < ActionController::Base
5
5
  include ErrorHandler
6
6
  include ControllerHelpers
7
+ include RateLimiter
8
+ include Auditable
7
9
 
8
10
  protect_from_forgery with: :exception
9
11
 
@@ -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
- redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(e.message)}"
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::Client.find_or_create_by!(origin: origin_uri) do |client|
62
- client.name = URI.parse(origin).host
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 params[:request_pii] && payload["email"]
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
- render json: { status: "signed_out" }
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"
@@ -24,11 +24,12 @@ module Shakha
24
24
  end
25
25
 
26
26
  def bad_request(exception)
27
- render json: { error: exception.message }, status: :bad_request
27
+ render json: { error: "Bad request" }, status: :bad_request
28
28
  end
29
29
 
30
30
  def bad_gateway(exception)
31
- render json: { error: exception.message }, status: :bad_gateway
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.4"
5
5
  end
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.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 handles Google OAuth + PKCE and gives your app a domain-scoped identity (pairwise_sub)
42
- and a signed id_token. No client signup. No unnecessary scopes. Just identity.
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: Minimal auth broker for Google OAuth with PKCE and pairwise subjects
132
+ summary: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
100
133
  test_files: []