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 +4 -4
- data/README.md +84 -27
- data/app/controllers/shakha/application_controller.rb +1 -1
- data/app/controllers/shakha/auth_controller.rb +61 -19
- data/app/controllers/shakha/session_controller.rb +5 -1
- data/app/models/shakha/user.rb +3 -2
- data/app/views/shakha/auth/error.html.erb +18 -0
- data/{generators → lib/generators}/shakha/install_generator.rb +3 -15
- data/{generators → lib/generators}/shakha/templates/initializer.rb.erb +1 -1
- data/{generators → lib/generators}/shakha/templates/migration.rb.erb +6 -6
- data/lib/shakha/config.rb +4 -1
- data/lib/shakha/config_validator.rb +26 -0
- data/lib/shakha/engine.rb +13 -76
- data/lib/shakha/error_handler.rb +3 -2
- data/lib/shakha/pkce.rb +39 -11
- data/lib/shakha/version.rb +1 -1
- data/lib/shakha.rb +7 -0
- metadata +41 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ff145da09437c51afcef947199c0225b2fb557a6e0559e1792e07d7af646250
|
|
4
|
+
data.tar.gz: d95b3e5a110d49b7defde447935240d27e0faf2a76e3874b4b05dba7f174336e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f61b03a6afdb461afde6274f2892b54f1f7979369023a1421b5037f99b333351c5c7bca5194c7f76db22d9f516594c4c737d96f501f82b8663c2ad5fafa472d1
|
|
7
|
+
data.tar.gz: 344441bf4fcfc2dc8061bb25fab5dfbe64485919405658cbbd16c376ffcdddc685682e0256d7e2fe2401c29e22a8122a157439e111748ccb8d6e0b8c266dc9a4
|
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
|
|
@@ -9,7 +9,7 @@ module Shakha
|
|
|
9
9
|
|
|
10
10
|
layout -> { false if request.format == :json }
|
|
11
11
|
|
|
12
|
-
rescue_from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
|
|
119
161
|
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/app/models/shakha/user.rb
CHANGED
|
@@ -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
|
|
10
|
-
validates :email, uniqueness:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
34
|
-
t.references :client,
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
17
|
+
get "authorize" => "auth#authorize"
|
|
18
|
+
get "callback" => "auth#callback"
|
|
19
|
+
post "token" => "auth#token"
|
|
20
|
+
get "error" => "auth#error"
|
|
70
21
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
22
|
+
get "session" => "session#show"
|
|
23
|
+
post "session/check" => "session#check"
|
|
24
|
+
delete "session" => "session#destroy"
|
|
74
25
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
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
|
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
|
-
|
|
38
|
+
pkce_record = {
|
|
35
39
|
verifier: verifier,
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
72
|
+
code_challenge = params[:code_challenge]
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/shakha/version.rb
CHANGED
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.
|
|
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
|
|
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: []
|
|
@@ -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:
|
|
130
|
+
summary: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
|
|
99
131
|
test_files: []
|