shakha 0.1.1 → 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/auth_controller.rb +49 -7
- data/app/controllers/shakha/session_controller.rb +5 -1
- data/lib/shakha/config_validator.rb +26 -0
- data/lib/shakha/engine.rb +4 -0
- data/lib/shakha/error_handler.rb +3 -2
- data/lib/shakha/version.rb +1 -1
- data/lib/shakha.rb +1 -0
- metadata +35 -4
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
|
|
@@ -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,8 @@ 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
|
-
|
|
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))}"
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def token
|
|
@@ -54,13 +56,52 @@ module Shakha
|
|
|
54
56
|
|
|
55
57
|
private
|
|
56
58
|
|
|
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
|
|
80
|
+
|
|
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."
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
57
92
|
def find_or_create_client
|
|
58
93
|
origin = request.origin || Shakha.config.app_origin
|
|
59
94
|
origin_uri = URI.parse(origin).origin
|
|
60
95
|
|
|
61
|
-
Shakha
|
|
62
|
-
|
|
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)
|
|
63
102
|
end
|
|
103
|
+
rescue ActiveRecord::RecordNotFound
|
|
104
|
+
raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
|
|
64
105
|
end
|
|
65
106
|
|
|
66
107
|
def build_google_auth_url(pkce)
|
|
@@ -114,12 +155,13 @@ module Shakha
|
|
|
114
155
|
|
|
115
156
|
payload = decode_id_token(id_token)
|
|
116
157
|
google_sub = payload["sub"]
|
|
117
|
-
pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
|
|
118
158
|
|
|
119
159
|
client = find_or_create_client
|
|
160
|
+
pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
|
|
161
|
+
|
|
120
162
|
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
|
|
121
163
|
|
|
122
|
-
if
|
|
164
|
+
if payload["email"]
|
|
123
165
|
user.assign_attributes(
|
|
124
166
|
email: payload["email"],
|
|
125
167
|
name: payload["name"],
|
|
@@ -142,7 +184,7 @@ module Shakha
|
|
|
142
184
|
expires: Shakha.config.session_lifetime.from_now
|
|
143
185
|
}
|
|
144
186
|
|
|
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
|
|
@@ -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
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/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.1.
|
|
4
|
+
version: 0.1.3
|
|
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
|
|
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: []
|
|
@@ -67,6 +97,7 @@ files:
|
|
|
67
97
|
- lib/generators/shakha/templates/migration.rb.erb
|
|
68
98
|
- lib/shakha.rb
|
|
69
99
|
- lib/shakha/config.rb
|
|
100
|
+
- lib/shakha/config_validator.rb
|
|
70
101
|
- lib/shakha/controller_helpers.rb
|
|
71
102
|
- lib/shakha/engine.rb
|
|
72
103
|
- lib/shakha/error_handler.rb
|
|
@@ -96,5 +127,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
96
127
|
requirements: []
|
|
97
128
|
rubygems_version: 3.6.9
|
|
98
129
|
specification_version: 4
|
|
99
|
-
summary:
|
|
130
|
+
summary: Headless Google OAuth broker with PKCE, pairwise subjects, and zero JavaScript
|
|
100
131
|
test_files: []
|