shakha 0.3.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/README.md +153 -91
- data/SECURITY.md +56 -0
- data/app/controllers/shakha/application_controller.rb +2 -3
- data/app/controllers/shakha/auth_controller.rb +40 -31
- data/app/controllers/shakha/session_controller.rb +13 -0
- data/app/models/shakha/session.rb +27 -2
- data/app/models/shakha/user.rb +1 -3
- data/app/views/shakha/auth/new.html.erb +4 -4
- data/app/views/shakha/layouts/shakha.html.erb +1 -1
- data/lib/generators/shakha/install/install_generator.rb +84 -0
- data/lib/generators/shakha/install/templates/create_shakha_tables.rb.erb +27 -0
- data/lib/generators/shakha/install/templates/shakha.rb.erb +39 -0
- data/lib/shakha/config.rb +6 -2
- data/lib/shakha/controller_helpers.rb +6 -4
- data/lib/shakha/engine.rb +10 -7
- data/lib/shakha/error_handler.rb +6 -1
- data/lib/shakha/pkce.rb +5 -4
- data/lib/shakha/providers/base.rb +2 -2
- data/lib/shakha/providers/github.rb +4 -2
- data/lib/shakha/providers/google.rb +26 -3
- data/lib/shakha/rate_limiter.rb +5 -5
- data/lib/shakha/version.rb +2 -2
- data/lib/shakha.rb +2 -1
- metadata +30 -19
- data/app/models/shakha/client.rb +0 -16
- data/lib/generators/shakha/install_generator.rb +0 -25
- data/lib/generators/shakha/templates/initializer.rb.erb +0 -29
- data/lib/generators/shakha/templates/migration.rb.erb +0 -45
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f67f490e63b0d03cfa0d2abd6b31e68ced7a20e17093b6ccc77f15209b4a2cd
|
|
4
|
+
data.tar.gz: 75edc086b7684691be80bc23ed40950d839a1c26b4070edcbe14447153d61995
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3797ed65b6d60130ac0afbe7dcb11790cacb4ab46feff2d74125575377a8b633aa290b5db2edf1d61b8fa87f3c66ce3980389424cca5d4d8394b729d67012a20
|
|
7
|
+
data.tar.gz: 9cc96c35365fdee1025a878d7abf344f0755e37cd14f94995566723a27f7a978e86532f4065a34ca9eec0b472c1cfd879509ee4f22c6c9faf6948fc5f17e326c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Until 1.0.0, minor
|
|
6
|
+
versions may include breaking changes; these are called out below.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.8.0] — 2026-07-27
|
|
11
|
+
|
|
12
|
+
First public release of the reworked gem.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Rewritten README, `CHANGELOG.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`,
|
|
16
|
+
issue/PR templates, and Dependabot.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Gemspec: accurate summary/description, GitHub homepage, `source_code_uri` /
|
|
20
|
+
`changelog_uri` / `bug_tracker_uri`, `rubygems_mfa_required`, and an explicit
|
|
21
|
+
packaged-file manifest.
|
|
22
|
+
- Removed superseded planning directories from the repo (kept in git history).
|
|
23
|
+
|
|
24
|
+
## [0.7.0] — 2026-07-26
|
|
25
|
+
|
|
26
|
+
Security hardening. Several changes are breaking; there are no production
|
|
27
|
+
installs of the intermediate 0.6.0 to migrate.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
- Verify the Google ID token: the OIDC `nonce` is now threaded through the
|
|
31
|
+
authorize step and checked on callback, and the `iss`, `aud`, and `exp` claims
|
|
32
|
+
are validated. The `aud` check rejects tokens minted for other clients.
|
|
33
|
+
- Deliver the session token via a single-use, 60-second exchange code by default
|
|
34
|
+
instead of placing it in the redirect URL, where it could leak into history,
|
|
35
|
+
`Referer` headers, and logs.
|
|
36
|
+
- Validate `return_to` against the app origin and `allowed_redirect_origins`
|
|
37
|
+
before it is stored, closing an open-redirect path in the OAuth flow.
|
|
38
|
+
- Run `bundler-audit` in CI; raise the `jwt` floor to `>= 2.10.3` and patch
|
|
39
|
+
`rails-html-sanitizer`, `json`, and `concurrent-ruby`.
|
|
40
|
+
- Add `SECURITY.md` with a private disclosure process and threat model.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- **Breaking:** the OAuth callback now redirects with `?code=` (a one-time
|
|
44
|
+
exchange code). Set `config.redirect_token_delivery = :token` to restore the
|
|
45
|
+
previous `?token=` behavior for one release.
|
|
46
|
+
- **Breaking:** removed the `shakha_clients` table and the `Shakha::Client`
|
|
47
|
+
model. Users and sessions no longer belong to a client. The sign-in page shows
|
|
48
|
+
`config.app_name` (default `"Shakha"`).
|
|
49
|
+
- Returning users' `email`/`name`/`picture` are refreshed on each sign-in.
|
|
50
|
+
- The OAuth `redirect_uri` is derived from the engine's actual mount point, so
|
|
51
|
+
Shakha works when mounted anywhere.
|
|
52
|
+
- An unknown or disabled provider returns `404` instead of raising a `500`.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- `config.redirect_token_delivery`, `config.app_name`.
|
|
56
|
+
- `POST /auth/shakha/session/exchange` — swap a one-time code for the token.
|
|
57
|
+
- The install generator injects `ActionDispatch::Cookies` into API-only hosts.
|
|
58
|
+
|
|
59
|
+
## [0.6.0] — 2026-07-26
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- A real test suite: an in-process dummy Rails app harness with full coverage of
|
|
63
|
+
the OAuth flow, session/bearer auth, providers, PKCE, models, and the
|
|
64
|
+
generator. Previously the suite could not load.
|
|
65
|
+
- GitHub Actions CI across Ruby 3.1–3.4 × Rails 7.1/8.0/8.1, plus RuboCop
|
|
66
|
+
(`rubocop-rails-omakase`).
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
- PKCE code verifier no longer carries base64 padding (`SecureRandom.urlsafe_base64`
|
|
70
|
+
takes `padding` positionally, so `padding: false` had passed a truthy hash).
|
|
71
|
+
- The OAuth callback no longer trips Rails' open-redirect protection.
|
|
72
|
+
- The install generator injects into `ApplicationController` using the
|
|
73
|
+
generator's `destination_root` rather than a CWD-relative path.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
- **Breaking (API-mode hosts):** the engine no longer force-inserts cookie and
|
|
77
|
+
session middleware into the host app. Re-run `rails generate shakha:install`,
|
|
78
|
+
or add `ActionDispatch::Cookies` to your middleware stack.
|
|
79
|
+
|
|
80
|
+
## [0.5.0] — 2026-05-20
|
|
81
|
+
- End-to-end verification of the SPA flow in a Rails 8.1 API app; runtime fixes
|
|
82
|
+
found during that testing; API-mode cookie/session compatibility.
|
|
83
|
+
|
|
84
|
+
## [0.3.0] — 2026-05-19
|
|
85
|
+
- Multi-provider system (Google and GitHub) wired into the engine; simplified
|
|
86
|
+
provider-scoped URLs; `rails generate shakha:install` generator.
|
|
87
|
+
|
|
88
|
+
## [0.2.0] — 2026-05-19
|
|
89
|
+
- Rework toward an SPA-first broker: fixed six fatal bugs and several security
|
|
90
|
+
issues; rewrote the auth flow and controllers; simplified models and config;
|
|
91
|
+
removed the JWT/JWKS/OIDC and pairwise-subject service infrastructure.
|
|
92
|
+
|
|
93
|
+
## [0.1.0] — 2026-05
|
|
94
|
+
- Initial prototype: Google OAuth broker with PKCE, database sessions, and a
|
|
95
|
+
sign-in page.
|
|
96
|
+
|
|
97
|
+
[Unreleased]: https://github.com/Asrat77/shakha/compare/v0.8.0...HEAD
|
|
98
|
+
[0.8.0]: https://github.com/Asrat77/shakha/compare/v0.7.0...v0.8.0
|
|
99
|
+
[0.7.0]: https://github.com/Asrat77/shakha/compare/v0.6.0...v0.7.0
|
|
100
|
+
[0.6.0]: https://github.com/Asrat77/shakha/compare/v0.5.0...v0.6.0
|
|
101
|
+
[0.5.0]: https://github.com/Asrat77/shakha/compare/v0.3.0...v0.5.0
|
|
102
|
+
[0.3.0]: https://github.com/Asrat77/shakha/compare/v0.2.0...v0.3.0
|
|
103
|
+
[0.2.0]: https://github.com/Asrat77/shakha/compare/v0.1.0...v0.2.0
|
|
104
|
+
[0.1.0]: https://github.com/Asrat77/shakha/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,93 +1,117 @@
|
|
|
1
1
|
# Shakha
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**SPA-first OAuth for Rails. One redirect. One token. Done.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://github.com/Asrat77/shakha/actions/workflows/ci.yml)
|
|
6
|
+
[](https://rubygems.org/gems/shakha)
|
|
7
|
+
[](LICENSE.txt)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Shakha is a headless OAuth broker engine for Rails APIs and monoliths. Your
|
|
10
|
+
frontend does a single redirect; Shakha runs the OAuth dance — PKCE, `state`,
|
|
11
|
+
and (for Google) `nonce` — against Google or GitHub, stores a revocable
|
|
12
|
+
database-backed session, and hands your frontend a session token it uses as a
|
|
13
|
+
cookie or `Authorization: Bearer` header.
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
gem "shakha"
|
|
11
|
-
```
|
|
15
|
+
No JWTs to issue or rotate. No Redis. No frontend SDK.
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## The flow
|
|
14
18
|
|
|
15
|
-
```bash
|
|
16
|
-
bin/rails generate migration CreateShakhaTables
|
|
17
19
|
```
|
|
20
|
+
1. Frontend: window.location = "https://api.example.com/auth/shakha/google?return_to=https://app.example.com/login"
|
|
21
|
+
2. Shakha: redirects to accounts.google.com (PKCE, state, nonce — handled)
|
|
22
|
+
3. Google: redirects back to /auth/shakha/google/callback?code=...
|
|
23
|
+
4. Shakha: exchanges the code, verifies the ID token, creates the User + Session
|
|
24
|
+
5. Shakha: redirects to https://app.example.com/login?code=<one-time-code>
|
|
25
|
+
6. Frontend: POST /auth/shakha/session/exchange { code } -> { token, expires_at }
|
|
26
|
+
7. Frontend: send "Authorization: Bearer <token>" on every API call
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The one-time code in step 5 keeps the session token out of the redirect URL (and
|
|
30
|
+
therefore out of browser history, `Referer` headers, and logs). It is single-use
|
|
31
|
+
and expires in 60 seconds.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Add the gem:
|
|
18
36
|
|
|
19
37
|
```ruby
|
|
20
|
-
|
|
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 :provider, null: false
|
|
32
|
-
t.string :uid, null: false
|
|
33
|
-
t.string :email
|
|
34
|
-
t.string :name
|
|
35
|
-
t.string :picture
|
|
36
|
-
t.timestamps
|
|
37
|
-
t.index [:provider, :uid], unique: true
|
|
38
|
-
t.index :email
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
create_table :shakha_sessions do |t|
|
|
42
|
-
t.references :user, foreign_key: { to_table: :shakha_users }
|
|
43
|
-
t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
|
|
44
|
-
t.string :token, null: false
|
|
45
|
-
t.string :ip_address
|
|
46
|
-
t.string :user_agent
|
|
47
|
-
t.timestamps
|
|
48
|
-
t.index :token, unique: true
|
|
49
|
-
t.index :created_at
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
38
|
+
gem "shakha"
|
|
53
39
|
```
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Create `config/initializers/shakha.rb`:
|
|
41
|
+
Mount the engine in `config/routes.rb`:
|
|
58
42
|
|
|
59
43
|
```ruby
|
|
60
|
-
Shakha
|
|
61
|
-
config.app_origin = ENV.fetch("SHAKHA_APP_ORIGIN", "http://localhost:3000")
|
|
62
|
-
config.service_url = ENV["SHAKHA_SERVICE_URL"] # omit for embedded mode
|
|
63
|
-
config.service_secret = ENV["SHAKHA_SERVICE_SECRET"]
|
|
64
|
-
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
65
|
-
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
66
|
-
config.session_lifetime = 30.days
|
|
67
|
-
end
|
|
44
|
+
mount Shakha::Engine => "/auth/shakha"
|
|
68
45
|
```
|
|
69
46
|
|
|
70
|
-
|
|
47
|
+
Run the installer and migrate:
|
|
71
48
|
|
|
72
49
|
```bash
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
export GOOGLE_CLIENT_ID="your-google-client-id"
|
|
76
|
-
export GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
|
50
|
+
bin/rails generate shakha:install
|
|
51
|
+
bin/rails db:migrate
|
|
77
52
|
```
|
|
78
53
|
|
|
79
|
-
|
|
54
|
+
The generator writes a migration and `config/initializers/shakha.rb`, includes
|
|
55
|
+
`Shakha::ControllerHelpers` in your `ApplicationController`, and — for API-only
|
|
56
|
+
apps — adds the cookie middleware Shakha needs.
|
|
57
|
+
|
|
58
|
+
### Configuration
|
|
59
|
+
|
|
60
|
+
Set these environment variables (the generated initializer reads them):
|
|
61
|
+
|
|
62
|
+
| Variable | Required | Purpose |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `APP_ORIGIN` | yes | Your Rails app's origin, e.g. `https://api.example.com` |
|
|
65
|
+
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | for Google | Google OAuth credentials |
|
|
66
|
+
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | for GitHub | GitHub OAuth credentials |
|
|
67
|
+
| `ALLOWED_REDIRECT_ORIGINS` | for a cross-origin SPA | Comma-separated frontend origins allowed as `return_to`, e.g. `https://app.example.com` |
|
|
68
|
+
|
|
69
|
+
Provider console redirect URIs (derived from `APP_ORIGIN` and the mount point):
|
|
70
|
+
|
|
71
|
+
- Google: `https://api.example.com/auth/shakha/google/callback`
|
|
72
|
+
- GitHub: `https://api.example.com/auth/shakha/github/callback`
|
|
73
|
+
|
|
74
|
+
## Frontend (React) example
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
// Start sign-in
|
|
78
|
+
const signIn = () => {
|
|
79
|
+
const returnTo = "https://app.example.com/login";
|
|
80
|
+
window.location =
|
|
81
|
+
`https://api.example.com/auth/shakha/google?return_to=${encodeURIComponent(returnTo)}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// On the return page (/login), swap the one-time code for a token
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const code = new URLSearchParams(window.location.search).get("code");
|
|
87
|
+
if (!code) return;
|
|
88
|
+
|
|
89
|
+
fetch("https://api.example.com/auth/shakha/session/exchange", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ code }),
|
|
93
|
+
})
|
|
94
|
+
.then((r) => r.json())
|
|
95
|
+
.then(({ token }) => {
|
|
96
|
+
localStorage.setItem("token", token);
|
|
97
|
+
return fetch("https://api.example.com/auth/shakha/session", {
|
|
98
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
99
|
+
});
|
|
100
|
+
})
|
|
101
|
+
.then((r) => r.json())
|
|
102
|
+
.then(({ user }) => setUser(user));
|
|
103
|
+
}, []);
|
|
104
|
+
```
|
|
80
105
|
|
|
81
|
-
##
|
|
106
|
+
## Rails monolith usage
|
|
82
107
|
|
|
83
|
-
|
|
108
|
+
Shakha ships a minimal sign-in page and sets an encrypted session cookie, so a
|
|
109
|
+
classic server-rendered app needs no JavaScript.
|
|
84
110
|
|
|
85
111
|
```erb
|
|
86
|
-
<%= link_to "Sign in
|
|
112
|
+
<%= link_to "Sign in", "/auth/shakha/google" %>
|
|
87
113
|
```
|
|
88
114
|
|
|
89
|
-
### Protect Routes
|
|
90
|
-
|
|
91
115
|
```ruby
|
|
92
116
|
class ApplicationController < ActionController::Base
|
|
93
117
|
include Shakha::ControllerHelpers
|
|
@@ -95,48 +119,86 @@ class ApplicationController < ActionController::Base
|
|
|
95
119
|
end
|
|
96
120
|
```
|
|
97
121
|
|
|
98
|
-
### Current User
|
|
99
|
-
|
|
100
122
|
```ruby
|
|
101
|
-
current_user
|
|
102
|
-
current_session
|
|
103
|
-
signed_in?
|
|
104
|
-
authenticate!
|
|
123
|
+
current_user # Shakha::User or nil
|
|
124
|
+
current_session # Shakha::Session or nil
|
|
125
|
+
signed_in? # boolean
|
|
126
|
+
authenticate! # 401 JSON for API requests, redirect to sign-in for HTML
|
|
105
127
|
```
|
|
106
128
|
|
|
107
|
-
|
|
129
|
+
Sign out:
|
|
108
130
|
|
|
109
131
|
```erb
|
|
110
|
-
<%=
|
|
132
|
+
<%= button_to "Sign out", "/auth/shakha/sign_out", method: :delete %>
|
|
111
133
|
```
|
|
112
134
|
|
|
113
|
-
|
|
135
|
+
## Endpoints
|
|
114
136
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
All paths are relative to the mount point (`/auth/shakha` above).
|
|
138
|
+
|
|
139
|
+
| Method | Path | Auth | Purpose |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| GET | `/` | — | Built-in sign-in page (monolith) |
|
|
142
|
+
| GET | `/:provider` | — | Start OAuth (`google`, `github`) |
|
|
143
|
+
| GET | `/:provider/callback` | — | OAuth callback (provider redirects here) |
|
|
144
|
+
| POST | `/session/exchange` | one-time code | Swap the code for `{ token, expires_at }` |
|
|
145
|
+
| GET | `/session` | cookie or bearer | Current user: `{ user: {...}, session: {...} }` |
|
|
146
|
+
| GET | `/session/check` | cookie or bearer | Lightweight `{ status: "active" }` / `401 { status: "expired" }` |
|
|
147
|
+
| DELETE | `/sign_out` | cookie or bearer | Destroy the session |
|
|
148
|
+
|
|
149
|
+
## Configuration reference
|
|
119
150
|
|
|
120
|
-
|
|
151
|
+
Set in `config/initializers/shakha.rb` via `Shakha.setup { |config| ... }`.
|
|
121
152
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
| Option | Default | Description |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `app_origin` | — | Your Rails app's origin; used to build the OAuth `redirect_uri` |
|
|
156
|
+
| `app_name` | `"Shakha"` | Name shown on the built-in sign-in page |
|
|
157
|
+
| `google_client_id` / `google_client_secret` | — | Google OAuth credentials |
|
|
158
|
+
| `github_client_id` / `github_client_secret` | — | GitHub OAuth credentials |
|
|
159
|
+
| `providers` | `[:google]` | Enabled providers |
|
|
160
|
+
| `allowed_redirect_origins` | `nil` | Extra origins permitted as `return_to` targets |
|
|
161
|
+
| `redirect_token_delivery` | `:exchange_code` | `:exchange_code` (one-time code) or `:token` (token in the URL) |
|
|
162
|
+
| `session_lifetime` | `30.days` | How long a session stays valid |
|
|
163
|
+
| `rate_limiting_enabled` | `false` | Rate-limit `authorize` (20/min) and `callback` (10/min) |
|
|
129
164
|
|
|
130
|
-
##
|
|
165
|
+
## Security
|
|
131
166
|
|
|
132
|
-
|
|
167
|
+
Shakha uses PKCE (S256), a timing-safe `state` check, and Google `nonce` +
|
|
168
|
+
ID-token claim (`iss`/`aud`/`exp`) verification. Session tokens are random
|
|
169
|
+
strings in the database — deleting the row revokes access immediately. See
|
|
170
|
+
[SECURITY.md](SECURITY.md) for the full threat model and how to report a
|
|
171
|
+
vulnerability.
|
|
133
172
|
|
|
134
|
-
|
|
173
|
+
## How it compares
|
|
135
174
|
|
|
136
|
-
|
|
175
|
+
- **OmniAuth** — a Rack middleware for the OAuth handshake; you still build
|
|
176
|
+
sessions, endpoints, and the SPA token hand-off yourself. Shakha ships the
|
|
177
|
+
whole path.
|
|
178
|
+
- **Devise** — excellent for password auth in a monolith; OAuth and API/SPA
|
|
179
|
+
token flows are add-ons. Shakha is OAuth-only and API-first.
|
|
180
|
+
- **Rodauth** — broader and more configurable, with its own conventions. Shakha
|
|
181
|
+
does one thing: an OAuth session broker for the API + SPA era.
|
|
182
|
+
|
|
183
|
+
## Writing your own provider
|
|
184
|
+
|
|
185
|
+
A provider implements five methods (`provider_name`, `scopes`, `authorize_url`,
|
|
186
|
+
`exchange_code`, `identity_from_response`) returning an identity hash of
|
|
187
|
+
`{ provider:, uid:, email:, name:, picture: }`. See
|
|
188
|
+
[`Shakha::Providers::Base`](lib/shakha/providers/base.rb) and the Google/GitHub
|
|
189
|
+
implementations alongside it.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
bundle install
|
|
195
|
+
bundle exec rake test
|
|
196
|
+
bundle exec rubocop
|
|
197
|
+
```
|
|
137
198
|
|
|
138
|
-
|
|
199
|
+
Test against another Rails version with `BUNDLE_GEMFILE=gemfiles/rails_71.gemfile`.
|
|
200
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
139
201
|
|
|
140
202
|
## License
|
|
141
203
|
|
|
142
|
-
MIT
|
|
204
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Shakha is pre-1.0. Security fixes are released only for the latest minor
|
|
6
|
+
version. Please stay current until 1.0 establishes a support window.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
**Do not open a public issue for security problems.**
|
|
11
|
+
|
|
12
|
+
Report privately through GitHub Security Advisories — the "Report a
|
|
13
|
+
vulnerability" button on the repository's Security tab. Include a description,
|
|
14
|
+
affected versions, and a reproduction if you have one. We aim to acknowledge
|
|
15
|
+
within 7 days and to coordinate disclosure once a fix is available.
|
|
16
|
+
|
|
17
|
+
## Threat model
|
|
18
|
+
|
|
19
|
+
Shakha is an OAuth session broker. It is responsible for the integrity of the
|
|
20
|
+
OAuth redirect flow and for session lifecycle; it deliberately delegates the
|
|
21
|
+
rest to the host application.
|
|
22
|
+
|
|
23
|
+
### What Shakha protects
|
|
24
|
+
|
|
25
|
+
- **Authorization-code interception** — PKCE (S256) on every flow.
|
|
26
|
+
- **CSRF on the OAuth round-trip** — a random `state`, stored in an encrypted,
|
|
27
|
+
http-only cookie and compared timing-safely on callback.
|
|
28
|
+
- **ID token replay / substitution (Google)** — the OIDC `nonce` is verified
|
|
29
|
+
against the value minted at authorize time, and the `iss`, `aud`, and `exp`
|
|
30
|
+
claims are validated. The `aud` check rejects tokens minted for other clients.
|
|
31
|
+
- **Open redirects** — `return_to` is validated against the app origin and an
|
|
32
|
+
explicit `allowed_redirect_origins` allowlist before it is stored, so the
|
|
33
|
+
post-login redirect can only reach approved origins.
|
|
34
|
+
- **Token exposure in URLs** — by default the callback redirects with a
|
|
35
|
+
single-use, 60-second exchange code rather than the session token itself; the
|
|
36
|
+
frontend swaps it for the token over a POST.
|
|
37
|
+
- **Session revocation** — sessions are random tokens stored in the database;
|
|
38
|
+
deleting the row revokes access immediately. No JWTs are issued, so there is
|
|
39
|
+
no standalone-token revocation gap.
|
|
40
|
+
- **Brute force on the auth endpoints** — optional rate limiting on `authorize`
|
|
41
|
+
and `callback`.
|
|
42
|
+
|
|
43
|
+
### What the host application owns
|
|
44
|
+
|
|
45
|
+
- **TLS** — Shakha assumes it runs behind HTTPS in production; the Google ID
|
|
46
|
+
token's signature is trusted on the basis of the TLS connection to Google's
|
|
47
|
+
token endpoint rather than re-verified.
|
|
48
|
+
- **CORS** — the host configures which origins may call its API.
|
|
49
|
+
- **Frontend XSS** — any script that runs in the frontend can read a bearer
|
|
50
|
+
token the SPA stores; Shakha cannot defend a compromised frontend.
|
|
51
|
+
- **Secret management** — `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_SECRET`, and
|
|
52
|
+
the Rails `secret_key_base` (which encrypts Shakha's cookies) are the host's
|
|
53
|
+
responsibility.
|
|
54
|
+
- **Rate-limit backing store** — atomic rate limiting requires a shared,
|
|
55
|
+
atomic cache (Redis/Memcached) in multi-process deployments; the default
|
|
56
|
+
in-memory store does not coordinate across processes.
|
|
@@ -4,9 +4,8 @@ module Shakha
|
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
5
|
include ErrorHandler
|
|
6
6
|
include ControllerHelpers
|
|
7
|
-
include RateLimiter
|
|
8
7
|
|
|
9
|
-
protect_from_forgery with: :exception
|
|
8
|
+
protect_from_forgery with: :exception, unless: -> { request.format.json? }
|
|
10
9
|
|
|
11
10
|
layout -> { false if request.format == :json }
|
|
12
11
|
|
|
@@ -18,4 +17,4 @@ module Shakha
|
|
|
18
17
|
render json: { error: "Invalid CSRF token" }, status: :unprocessable_entity
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
|
-
end
|
|
20
|
+
end
|
|
@@ -6,11 +6,11 @@ require "uri"
|
|
|
6
6
|
module Shakha
|
|
7
7
|
class AuthController < ApplicationController
|
|
8
8
|
include PKCEMixin
|
|
9
|
+
include RateLimiter
|
|
9
10
|
|
|
10
|
-
skip_before_action :verify_authenticity_token, only: [:callback]
|
|
11
|
+
skip_before_action :verify_authenticity_token, only: [ :callback ]
|
|
11
12
|
|
|
12
13
|
def new
|
|
13
|
-
@client = find_or_create_client
|
|
14
14
|
@return_to = sanitize_return_to(params[:return_to])
|
|
15
15
|
@providers = Shakha.config.providers
|
|
16
16
|
end
|
|
@@ -19,11 +19,11 @@ module Shakha
|
|
|
19
19
|
provider = resolve_provider
|
|
20
20
|
pkce = create_pkce_bundle
|
|
21
21
|
|
|
22
|
-
redirect_uri = "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
|
|
23
22
|
auth_url = provider.authorize_url(
|
|
24
23
|
state: pkce[:state],
|
|
25
24
|
code_challenge: pkce[:challenge],
|
|
26
|
-
redirect_uri:
|
|
25
|
+
redirect_uri: callback_redirect_uri(provider),
|
|
26
|
+
nonce: pkce[:nonce]
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
redirect_to auth_url, allow_other_host: true
|
|
@@ -36,14 +36,14 @@ module Shakha
|
|
|
36
36
|
token_response = provider.exchange_code(
|
|
37
37
|
code: params[:code],
|
|
38
38
|
code_verifier: pkce_result[:verifier],
|
|
39
|
-
redirect_uri:
|
|
39
|
+
redirect_uri: callback_redirect_uri(provider)
|
|
40
40
|
)
|
|
41
41
|
|
|
42
|
-
identity = provider.identity_from_response(token_response)
|
|
42
|
+
identity = provider.identity_from_response(token_response, expected_nonce: pkce_result[:nonce])
|
|
43
43
|
user = find_or_create_user(provider.provider_name, identity)
|
|
44
44
|
session_record = create_session(user)
|
|
45
45
|
set_session_cookie(session_record)
|
|
46
|
-
redirect_to build_return_url(pkce_result[:return_to], session_record)
|
|
46
|
+
redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
|
|
47
47
|
|
|
48
48
|
rescue PKCEError, OAuthError => e
|
|
49
49
|
handle_auth_failure(e, pkce_result)
|
|
@@ -53,9 +53,10 @@ module Shakha
|
|
|
53
53
|
current_session&.destroy
|
|
54
54
|
cookies.delete(:shakha_session_token)
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
if request.format.json?
|
|
57
|
+
render json: { status: "signed_out" }
|
|
58
|
+
else
|
|
59
|
+
redirect_to params[:return_to].presence || "/"
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -66,26 +67,35 @@ module Shakha
|
|
|
66
67
|
private
|
|
67
68
|
|
|
68
69
|
def resolve_provider
|
|
69
|
-
|
|
70
|
-
Shakha
|
|
70
|
+
name = (params[:provider] || :google).to_sym
|
|
71
|
+
raise ProviderNotFound, name.to_s unless Shakha.config.providers.include?(name)
|
|
72
|
+
|
|
73
|
+
Shakha::Providers.resolve(name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Built from the engine's actual mount point, so Shakha works no matter
|
|
77
|
+
# where the host app mounts it.
|
|
78
|
+
def callback_redirect_uri(provider)
|
|
79
|
+
origin = URI.parse(Shakha.config.app_origin)
|
|
80
|
+
callback_url(provider: provider.provider_name,
|
|
81
|
+
host: origin.host, port: origin.port, protocol: origin.scheme)
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
def find_or_create_user(provider_name, identity)
|
|
74
|
-
Shakha::User.
|
|
85
|
+
user = Shakha::User.find_or_initialize_by(
|
|
75
86
|
provider: provider_name.to_s,
|
|
76
87
|
uid: identity[:uid]
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
)
|
|
89
|
+
user.email = identity[:email]
|
|
90
|
+
user.name = identity[:name]
|
|
91
|
+
user.picture = identity[:picture]
|
|
92
|
+
user.save!
|
|
93
|
+
user
|
|
83
94
|
end
|
|
84
95
|
|
|
85
96
|
def create_session(user)
|
|
86
97
|
Shakha::Session.create!(
|
|
87
98
|
user: user,
|
|
88
|
-
client: find_or_create_client,
|
|
89
99
|
ip_address: request.remote_ip,
|
|
90
100
|
user_agent: request.user_agent
|
|
91
101
|
)
|
|
@@ -104,8 +114,14 @@ module Shakha
|
|
|
104
114
|
def build_return_url(return_to, session_record)
|
|
105
115
|
uri = URI.parse(return_to || "/")
|
|
106
116
|
existing = URI.decode_www_form(uri.query || "").to_h
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
|
|
118
|
+
if Shakha.config.redirect_token_delivery == :token
|
|
119
|
+
existing["token"] = session_record.token
|
|
120
|
+
existing["expires_at"] = session_record.expires_at.iso8601
|
|
121
|
+
else
|
|
122
|
+
existing["code"] = session_record.generate_exchange_code!
|
|
123
|
+
end
|
|
124
|
+
|
|
109
125
|
uri.query = URI.encode_www_form(existing)
|
|
110
126
|
uri.to_s
|
|
111
127
|
end
|
|
@@ -116,7 +132,8 @@ module Shakha
|
|
|
116
132
|
if request.format.json? || api_request?
|
|
117
133
|
render json: { error: user_facing_error(exception) }, status: :unauthorized
|
|
118
134
|
else
|
|
119
|
-
redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}"
|
|
135
|
+
redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}",
|
|
136
|
+
allow_other_host: true
|
|
120
137
|
end
|
|
121
138
|
end
|
|
122
139
|
|
|
@@ -145,14 +162,6 @@ module Shakha
|
|
|
145
162
|
Shakha.config.allowed_redirect_origins&.include?(origin) || false
|
|
146
163
|
end
|
|
147
164
|
|
|
148
|
-
def find_or_create_client
|
|
149
|
-
origin = request.origin || Shakha.config.app_origin
|
|
150
|
-
origin_uri = URI.parse(origin).origin
|
|
151
|
-
Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
|
|
152
|
-
client.name = URI.parse(origin).host
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
165
|
def user_facing_error(exception)
|
|
157
166
|
case exception
|
|
158
167
|
when PKCEError then "Authentication failed. Please try again."
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Shakha
|
|
4
4
|
class SessionController < ApplicationController
|
|
5
|
+
skip_before_action :verify_authenticity_token, only: :exchange
|
|
6
|
+
|
|
7
|
+
# POST /session/exchange — swap a one-time code for the session token.
|
|
8
|
+
def exchange
|
|
9
|
+
session_record = Shakha::Session.exchange(params[:code])
|
|
10
|
+
if session_record
|
|
11
|
+
render json: { token: session_record.token,
|
|
12
|
+
expires_at: session_record.expires_at.iso8601 }
|
|
13
|
+
else
|
|
14
|
+
render json: { error: "Invalid or expired code" }, status: :unauthorized
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
5
18
|
def show
|
|
6
19
|
unless signed_in?
|
|
7
20
|
return render json: { error: "Authentication required" }, status: :unauthorized
|