shakha 0.1.0 → 0.1.3

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: dab553b64eb62de5d3a4225c5a4aa4fb470f8c6cf4aeea518f549dd664b7e67a
4
- data.tar.gz: 0206f67ee329e747bad7da7cd3082c6be17811e19ef97ec67843d61bf50922de
3
+ metadata.gz: 7ff145da09437c51afcef947199c0225b2fb557a6e0559e1792e07d7af646250
4
+ data.tar.gz: d95b3e5a110d49b7defde447935240d27e0faf2a76e3874b4b05dba7f174336e
5
5
  SHA512:
6
- metadata.gz: 1704c398b0c6cdfa415a08f0662574eab25acb72b28ac9d8e8b83658750793111aeb9f24248ba22641ebc566500d6fbef2e63b250a895f43d6c7eb451c877d07
7
- data.tar.gz: 9eab6f79cb46672c4df6dfc26507e91518616ac67ae7119bd07bfe7a177e7488f97918c09280eafcf8192953014d264bbc5beacc9c6bd5ced23fbb2e885f1e26
6
+ metadata.gz: f61b03a6afdb461afde6274f2892b54f1f7979369023a1421b5037f99b333351c5c7bca5194c7f76db22d9f516594c4c737d96f501f82b8663c2ad5fafa472d1
7
+ data.tar.gz: 344441bf4fcfc2dc8061bb25fab5dfbe64485919405658cbbd16c376ffcdddc685682e0256d7e2fe2401c29e22a8122a157439e111748ccb8d6e0b8c266dc9a4
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
@@ -9,7 +9,7 @@ module Shakha
9
9
 
10
10
  layout -> { false if request.format == :json }
11
11
 
12
- rescue_from ActiveSupport::ActionController::InvalidAuthenticityToken, with: :invalid_csrf_token
12
+ rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_csrf_token
13
13
 
14
14
  private
15
15
 
@@ -11,24 +11,25 @@ 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
 
21
22
  google_auth_url = build_google_auth_url(pkce)
22
23
 
23
- redirect_to google_auth_url
24
+ redirect_to google_auth_url, allow_other_host: true
24
25
  end
25
26
 
26
27
  def callback
27
- verifier = verify_pkce!(params[:code])
28
-
29
- exchange_code_for_tokens(params[:code], verifier)
28
+ pkce_result = verify_pkce!(params[:code], params[:state])
29
+ exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
30
30
  rescue PKCEError, GoogleOAuthError => e
31
- redirect_to shakha.error_path(message: e.message)
31
+ Rails.logger.warn("[Shakha] Auth error: #{e.class}: #{e.message}")
32
+ redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(user_facing_error(e))}"
32
33
  end
33
34
 
34
35
  def token
@@ -55,17 +56,58 @@ module Shakha
55
56
 
56
57
  private
57
58
 
58
- def find_or_create_client
59
- origin = URI.parse(request.origin).origin
59
+ def sanitize_return_to(raw)
60
+ return "/" if raw.blank?
61
+
62
+ uri = URI.parse(raw)
63
+ return "/" if uri.host.present? && ![app_origin_host, client_origin_host].include?(uri.host)
64
+ return "/" unless uri.path.present? && uri.path.start_with?("/")
65
+
66
+ uri.path
67
+ rescue URI::InvalidURIError
68
+ "/"
69
+ end
70
+
71
+ def app_origin_host
72
+ URI.parse(Shakha.config.app_origin).host
73
+ end
74
+
75
+ def client_origin_host
76
+ URI.parse(Shakha.config.service_base_url).host
77
+ rescue URI::InvalidURIError
78
+ nil
79
+ end
60
80
 
61
- Shakha::Client.find_or_create_by!(origin: origin) do |client|
62
- client.name = URI.parse(request.origin).host
81
+ def user_facing_error(exception)
82
+ case exception
83
+ when PKCEError
84
+ "Authentication failed. Please try again."
85
+ when GoogleOAuthError
86
+ "Unable to sign in with Google. Please try again later."
87
+ else
88
+ "An unexpected error occurred. Please try again."
63
89
  end
64
90
  end
65
91
 
92
+ def find_or_create_client
93
+ origin = request.origin || Shakha.config.app_origin
94
+ origin_uri = URI.parse(origin).origin
95
+
96
+ if Shakha.config.embedded?
97
+ Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
98
+ client.name = URI.parse(origin).host
99
+ end
100
+ else
101
+ Shakha::Client.find_by!(origin: origin_uri)
102
+ end
103
+ rescue ActiveRecord::RecordNotFound
104
+ raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
105
+ end
106
+
66
107
  def build_google_auth_url(pkce)
67
108
  client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
68
- redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
109
+ base_url = Shakha.config.service_base_url || "http://localhost:3000"
110
+ redirect_uri = "#{base_url}/auth/shakha/callback"
69
111
 
70
112
  scopes = ["openid", "email", "profile"].join(" ")
71
113
  scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
@@ -87,10 +129,11 @@ module Shakha
87
129
  end.to_s
88
130
  end
89
131
 
90
- def exchange_code_for_tokens(code, verifier)
132
+ def exchange_code_for_tokens(code, verifier, return_to = "/")
91
133
  client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
92
134
  client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
93
- redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
135
+ base_url = Shakha.config.service_base_url || "http://localhost:3000"
136
+ redirect_uri = "#{base_url}/auth/shakha/callback"
94
137
 
95
138
  response = http_post(
96
139
  "https://oauth2.googleapis.com/token",
@@ -112,12 +155,13 @@ module Shakha
112
155
 
113
156
  payload = decode_id_token(id_token)
114
157
  google_sub = payload["sub"]
115
- pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
116
158
 
117
159
  client = find_or_create_client
118
- user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub)
160
+ pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
119
161
 
120
- if params[:request_pii] && payload["email"]
162
+ user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
163
+
164
+ if payload["email"]
121
165
  user.assign_attributes(
122
166
  email: payload["email"],
123
167
  name: payload["name"],
@@ -140,9 +184,7 @@ module Shakha
140
184
  expires: Shakha.config.session_lifetime.from_now
141
185
  }
142
186
 
143
- return_to = pkce_state&.dig(:return_to) || "/"
144
-
145
- redirect_to return_to
187
+ redirect_to sanitize_return_to(return_to)
146
188
  end
147
189
 
148
190
  def exchange_code_for_id_token(code, verifier)
@@ -27,7 +27,11 @@ module Shakha
27
27
  def destroy
28
28
  current_session&.destroy
29
29
  cookies.delete(:shakha_session_token)
30
- render json: { status: "signed_out" }
30
+
31
+ respond_to do |format|
32
+ format.html { redirect_to params[:return_to].presence || "/" }
33
+ format.json { render json: { status: "signed_out" } }
34
+ end
31
35
  end
32
36
  end
33
37
  end
@@ -4,10 +4,11 @@ module Shakha
4
4
  class User < ::ApplicationRecord
5
5
  self.table_name = "shakha_users"
6
6
 
7
+ belongs_to :client, class_name: "Shakha::Client"
7
8
  has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
8
9
 
9
- validates :pairwise_sub, presence: true, uniqueness: true
10
- validates :email, uniqueness: true, allow_blank: true
10
+ validates :pairwise_sub, presence: true
11
+ validates :email, uniqueness: { scope: :client_id }, allow_blank: true
11
12
 
12
13
  def can_access?(resource)
13
14
  true
@@ -0,0 +1,18 @@
1
+ <% content_for :title, "Authentication Error" %>
2
+
3
+ <div class="shakha-container">
4
+ <div class="shakha-card shakha-card-error">
5
+ <div class="shakha-error-icon">
6
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7
+ <circle cx="12" cy="12" r="10"/>
8
+ <line x1="12" y1="8" x2="12" y2="12"/>
9
+ <line x1="12" y1="16" x2="12.01" y2="16"/>
10
+ </svg>
11
+ </div>
12
+
13
+ <h1>Authentication Failed</h1>
14
+ <p class="shakha-error-message"><%= params[:message] || "An error occurred" %></p>
15
+
16
+ <%= link_to "Try Again", "/auth/shakha", class: "shakha-button shakha-button-primary" %>
17
+ </div>
18
+ </div>
@@ -1,8 +1,4 @@
1
1
  # frozen_string_literal: true
2
- # This generator creates a migration for the Shakha tables
3
-
4
- require "rails/generators/active_record/migration"
5
- require "rails/generators/active_record/migration/migration_generator"
6
2
 
7
3
  module Shakha
8
4
  class InstallGenerator < Rails::Generators::Base
@@ -17,21 +13,13 @@ module Shakha
17
13
  def create_migration
18
14
  return if options[:skip_migration]
19
15
 
20
- migration_template(
21
- "migration.rb.erb",
22
- "db/migrate/create_shakha_tables.rb",
23
- migration_version: migration_version
24
- )
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"
25
19
  end
26
20
 
27
21
  def add_routes
28
22
  route 'mount Shakha::Engine => "/auth/shakha", as: :shakha'
29
23
  end
30
-
31
- private
32
-
33
- def migration_version
34
- ">= 7.1" ? "[7.1]" : ""
35
- end
36
24
  end
37
25
  end
@@ -20,7 +20,7 @@ Shakha.setup do |config|
20
20
  config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
21
21
  config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
22
22
  config.issuer = ENV.fetch("SHAKHA_ISSUER", "https://shakha.dev")
23
- config.session_lifetime = ENV.fetch("SHAKHA_SESSION_LIFETIME", "30.days")
23
+ config.session_lifetime = 30.days
24
24
 
25
25
  # JWT signing keys (required for service mode)
26
26
  config.signing_key = ENV["SHAKHA_SIGNING_KEY"]
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateShakhaTables < ActiveRecord::Migration[7.1]
4
4
  def change
5
- create_table :shakha_clients, id: :uuid do |t|
5
+ create_table :shakha_clients do |t|
6
6
  t.string :name, null: false
7
7
  t.string :origin, null: false
8
8
  t.string :client_id
@@ -14,12 +14,12 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
14
14
  add_index :shakha_clients, :origin, unique: true
15
15
  add_index :shakha_clients, :client_id, unique: true
16
16
 
17
- create_table :shakha_users, id: :uuid do |t|
17
+ create_table :shakha_users do |t|
18
18
  t.string :pairwise_sub, null: false
19
19
  t.string :email
20
20
  t.string :name
21
21
  t.string :picture
22
- t.references :client, type: :uuid, null: false
22
+ t.references :client, null: false
23
23
 
24
24
  t.timestamps
25
25
  end
@@ -27,11 +27,11 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
27
27
  add_index :shakha_users, :pairwise_sub, unique: true
28
28
  add_index :shakha_users, :email
29
29
 
30
- create_table :shakha_sessions, id: :uuid do |t|
30
+ create_table :shakha_sessions do |t|
31
31
  t.string :token, null: false
32
32
  t.string :jti, null: false
33
- t.references :user, type: :uuid, null: false
34
- t.references :client, type: :uuid, null: false
33
+ t.references :user, null: false
34
+ t.references :client, null: false
35
35
  t.string :ip_address
36
36
  t.string :user_agent
37
37
 
data/lib/shakha/config.rb CHANGED
@@ -8,7 +8,10 @@ module Shakha
8
8
  :google_client_id,
9
9
  :google_client_secret,
10
10
  :issuer,
11
- :session_lifetime
11
+ :session_lifetime,
12
+ :signing_key,
13
+ :verification_key,
14
+ :key_id
12
15
 
13
16
  def initialize
14
17
  @session_lifetime = 30.days
@@ -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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ostruct"
4
-
5
3
  module Shakha
6
4
  class Engine < ::Rails::Engine
7
5
  isolate_namespace Shakha
@@ -9,85 +7,24 @@ module Shakha
9
7
  config.app_middleware.use Shakha::Middleware
10
8
 
11
9
  config.after_initialize do
12
- if Shakha.config.app_origin.blank?
13
- raise ConfigurationError, "Shakha.app_origin must be set"
14
- end
15
- end
16
-
17
- class EngineRouter
18
- def self.draw
19
- Drawer.new
20
- end
21
-
22
- class Drawer
23
- def initialize
24
- @routes = []
25
- end
26
-
27
- def resources(*args, &block)
28
- resource_options = args.last.is_a?(Hash) ? args.pop : {}
29
- resource_name = args.first
30
-
31
- @routes << { type: :resources, name: resource_name, options: resource_options, block: block }
32
- end
33
-
34
- def resource(*args, &block)
35
- resource_options = args.last.is_a?(Hash) ? args.pop : {}
36
- resource_name = args.first
37
-
38
- @routes << { type: :resource, name: resource_name, options: resource_options, block: block }
39
- end
40
-
41
- def get(path, to:, as: nil)
42
- @routes << { type: :get, path: path, to: to, as: as }
43
- end
44
-
45
- def post(path, to:, as: nil)
46
- @routes << { type: :post, path: path, to: to, as: as }
47
- end
48
-
49
- def match(path, to:, via:, as: nil)
50
- @routes << { type: :match, path: path, to: to, via: via, as: as }
51
- end
52
-
53
- def routes
54
- @routes
55
- end
56
- end
10
+ Shakha::ConfigValidator.validate!(Shakha.config)
57
11
  end
58
12
 
59
- initializer "shakha.routes" do |app|
60
- Shakha::EngineRouter.draw do
61
- get "/auth/shakha", to: "auth#new", as: :new_auth
62
- get "/auth/shakha/authorize", to: "auth#authorize", as: :authorize
63
- get "/auth/shakha/callback", to: "auth#callback", as: :callback
64
- post "/auth/shakha/token", to: "auth#token", as: :token
65
- get "/auth/shakha/error", to: "auth#error", as: :error
13
+ # Engine routes - these should be relative paths
14
+ routes do
15
+ root to: "auth#new"
66
16
 
67
- get "/auth/shakha/session", to: "session#show", as: :session
68
- post "/auth/shakha/session/check", to: "session#check", as: :check_session
69
- delete "/auth/shakha/session", to: "session#destroy", as: :destroy_session
17
+ get "authorize" => "auth#authorize"
18
+ get "callback" => "auth#callback"
19
+ post "token" => "auth#token"
20
+ get "error" => "auth#error"
70
21
 
71
- get "/.well-known/jwks.json", to: "jwks#show"
72
- get "/.well-known/openid-configuration", to: "openid#configuration"
73
- end
22
+ get "session" => "session#show"
23
+ post "session/check" => "session#check"
24
+ delete "session" => "session#destroy"
74
25
 
75
- Shakha::EngineRouter.routes.each do |route|
76
- case route[:type]
77
- when :get
78
- app.routes.append do
79
- get route[:path], to: route[:to], as: route[:as]
80
- end
81
- when :post
82
- app.routes.append do
83
- post route[:path], to: route[:to], as: route[:as]
84
- end
85
- when :match
86
- app.routes.append do
87
- match route[:path], to: route[:to], as: route[:as], via: route[:via]
88
- end
89
- end
90
- end
26
+ get ".well-known/jwks.json" => "jwks#show"
27
+ get ".well-known/openid-configuration" => "openid#configuration"
91
28
  end
92
29
  end
93
30
  end
@@ -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
data/lib/shakha/pkce.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "active_support/concern"
4
5
 
5
6
  module Shakha
@@ -8,6 +9,8 @@ module Shakha
8
9
 
9
10
  CODE_VERIFIER_LENGTH = 64
10
11
  CODE_CHALLENGE_METHOD = "S256"
12
+ PKCE_COOKIE_NAME = "shakha_pkce"
13
+ PKCE_COOKIE_EXPIRY_SECONDS = 600
11
14
 
12
15
  class << self
13
16
  def generate_code_verifier
@@ -30,31 +33,56 @@ module Shakha
30
33
  verifier = PKCEMixin.generate_code_verifier
31
34
  challenge = PKCEMixin.generate_code_challenge(verifier)
32
35
  state = SecureRandom.urlsafe_base64(32)
36
+ return_to = params[:return_to] || "/"
33
37
 
34
- session[:shakha_pkce] = {
38
+ pkce_record = {
35
39
  verifier: verifier,
36
- state: state,
37
- return_to: params[:return_to] || request.referer || "/"
40
+ return_to: return_to
41
+ }
42
+
43
+ cookies[PKCE_COOKIE_NAME] = {
44
+ value: pkce_record.merge(state: state).to_json,
45
+ httponly: true,
46
+ secure: Rails.env.production?,
47
+ same_site: :lax,
48
+ expires: Time.now.utc + PKCE_COOKIE_EXPIRY_SECONDS
38
49
  }
39
50
 
40
51
  { challenge: challenge, state: state }
41
52
  end
42
53
 
43
- def verify_pkce!(code_verifier)
44
- stored = session[:shakha_pkce]
54
+ def verify_pkce!(code_verifier, state_param)
55
+ pkce_json = cookies[PKCE_COOKIE_NAME]
56
+
57
+ raise PKCEError, "No PKCE session found" unless pkce_json
58
+
59
+ pkce_data = JSON.parse(pkce_json).with_indifferent_access
60
+
61
+ raise PKCEError, "No PKCE session found" unless pkce_data
45
62
 
46
- raise PKCEError, "No PKCE session found" unless stored
47
- raise PKCEError, "State mismatch" unless stored[:state] == params[:state]
63
+ stored_state = pkce_data[:state]
64
+ stored_verifier = pkce_data[:verifier]
65
+ stored_return_to = pkce_data[:return_to]
66
+
67
+ cookies.delete(PKCE_COOKIE_NAME)
68
+
69
+ raise PKCEError, "State mismatch" unless stored_state == state_param
48
70
 
49
71
  computed = PKCEMixin.generate_code_challenge(code_verifier)
50
- raise PKCEError, "Invalid code verifier" unless computed == params[:code_challenge]
72
+ code_challenge = params[:code_challenge]
51
73
 
52
- session.delete(:shakha_pkce)
53
- stored[:verifier]
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 }
54
79
  end
55
80
 
56
81
  def pkce_state
57
- session[:shakha_pkce]
82
+ pkce_json = cookies[PKCE_COOKIE_NAME]
83
+ return nil unless pkce_json
84
+
85
+ JSON.parse(pkce_json).with_indifferent_access
58
86
  end
59
87
  end
60
88
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/shakha.rb CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  require "shakha/version"
4
4
  require "shakha/config"
5
+ require "shakha/config_validator"
6
+ require "shakha/pairwise"
7
+ require "shakha/jwt_handler"
8
+ require "shakha/pkce"
9
+ require "shakha/error_handler"
10
+ require "shakha/controller_helpers"
11
+ require "shakha/middleware"
5
12
  require "shakha/engine"
6
13
 
7
14
  module Shakha
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.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
@@ -27,19 +27,49 @@ dependencies:
27
27
  name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
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
36
39
  requirements:
37
- - - "~>"
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
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
+ - - ">="
38
51
  - !ruby/object:Gem::Version
39
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: []
@@ -58,14 +88,16 @@ files:
58
88
  - app/models/shakha/session.rb
59
89
  - app/models/shakha/user.rb
60
90
  - app/views/shakha/auth/callback.html.erb
91
+ - app/views/shakha/auth/error.html.erb
61
92
  - app/views/shakha/auth/new.html.erb
62
93
  - app/views/shakha/errors/show.html.erb
63
94
  - app/views/shakha/layouts/shakha.html.erb
64
- - generators/shakha/install_generator.rb
65
- - generators/shakha/templates/initializer.rb.erb
66
- - generators/shakha/templates/migration.rb.erb
95
+ - lib/generators/shakha/install_generator.rb
96
+ - lib/generators/shakha/templates/initializer.rb.erb
97
+ - lib/generators/shakha/templates/migration.rb.erb
67
98
  - lib/shakha.rb
68
99
  - lib/shakha/config.rb
100
+ - lib/shakha/config_validator.rb
69
101
  - lib/shakha/controller_helpers.rb
70
102
  - lib/shakha/engine.rb
71
103
  - lib/shakha/error_handler.rb
@@ -95,5 +127,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
127
  requirements: []
96
128
  rubygems_version: 3.6.9
97
129
  specification_version: 4
98
- summary: Minimal auth broker for Google OAuth with PKCE and pairwise subjects
130
+ summary: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
99
131
  test_files: []