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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f2fb69e4f483c981312b4211c9205e9ae5d0d100a0d3d8f0d61f693478c8369
4
- data.tar.gz: 6dfb50d826c90ea54ed5453d7eebad7a94bf93cea02a1bb407fbd69a61da421e
3
+ metadata.gz: 8f67f490e63b0d03cfa0d2abd6b31e68ced7a20e17093b6ccc77f15209b4a2cd
4
+ data.tar.gz: 75edc086b7684691be80bc23ed40950d839a1c26b4070edcbe14447153d61995
5
5
  SHA512:
6
- metadata.gz: 1bac5c8e1d92fb2997713d5ad19d7b16327fc21ec7e7520adf09d736bbe2c15c1cb72ef7c859798cba62a8a012819a1fb4863bd6c49830cd10d98f3082d4afc9
7
- data.tar.gz: 8e9ae9652bb51b48bc30623305113e75c2a1c56676ec17a4e9503f4dc9694f55cf87d1d58fa9a2aaee956e47fa8ee1ecdc35024f3500c616e228942f42efbfac
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
- Headless Google OAuth broker for Rails PKCE, pairwise subjects, zero JavaScript.
3
+ **SPA-first OAuth for Rails. One redirect. One token. Done.**
4
4
 
5
- Same Google account, different IDs per application. Built DHH-style: database sessions, Turbo native, single "Continue with Google" button.
5
+ [![CI](https://github.com/Asrat77/shakha/actions/workflows/ci.yml/badge.svg)](https://github.com/Asrat77/shakha/actions/workflows/ci.yml)
6
+ [![Gem Version](https://badge.fury.io/rb/shakha.svg)](https://rubygems.org/gems/shakha)
7
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
8
 
7
- ## Installation
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
- ```ruby
10
- gem "shakha"
11
- ```
15
+ No JWTs to issue or rotate. No Redis. No frontend SDK.
12
16
 
13
- Run the migration:
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
- 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 :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
- ## Configuration
56
-
57
- Create `config/initializers/shakha.rb`:
41
+ Mount the engine in `config/routes.rb`:
58
42
 
59
43
  ```ruby
60
- Shakha.setup do |config|
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
- Environment variables:
47
+ Run the installer and migrate:
71
48
 
72
49
  ```bash
73
- export SHAKHA_APP_ORIGIN="https://yourapp.com"
74
- export SHAKHA_SERVICE_SECRET="your-secret-key"
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
- Google Cloud Console redirect URI: `https://yourapp.com/auth/shakha/callback`
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
- ## Usage
106
+ ## Rails monolith usage
82
107
 
83
- ### Sign In
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 with Google", shakha.new_auth_path %>
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 # Shakha::User or nil
102
- current_session # Shakha::Session or nil
103
- signed_in? # boolean
104
- authenticate! # redirects to login if not signed in
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
- ### Sign Out
129
+ Sign out:
108
130
 
109
131
  ```erb
110
- <%= link_to "Sign out", shakha.session_path, data: { turbo_method: :delete } %>
132
+ <%= button_to "Sign out", "/auth/shakha/sign_out", method: :delete %>
111
133
  ```
112
134
 
113
- ### JWT Verification (API Mode)
135
+ ## Endpoints
114
136
 
115
- ```ruby
116
- payload = Shakha.verify_token(id_token)
117
- user_id = payload[:pairwise_sub]
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
- ## Architecture
151
+ Set in `config/initializers/shakha.rb` via `Shakha.setup { |config| ... }`.
121
152
 
122
- - **PKCE** S256 code challenges on every flow
123
- - **Pairwise subjects** — domain-scoped user identifiers (HMAC-SHA256)
124
- - **ES256 JWTs**signed with JWKS endpoint at `.well-known/jwks.json`
125
- - **OpenID Connect** `.well-known/openid-configuration` endpoint
126
- - **Database sessions**DHH-style, no Redis
127
- - **Turbo native**zero JavaScript needed
128
- - **Embedded or standalone** runs as Rails engine or headless service
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
- ## Modes
165
+ ## Security
131
166
 
132
- ### Embedded (default)
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
- 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.
173
+ ## How it compares
135
174
 
136
- ### Service (multi-tenant)
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
- Set `SHAKHA_SERVICE_URL` and register each app's origin in `shakha_clients`. Each app gets different pairwise subjects for the same Google user.
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: 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: "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
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
- respond_to do |format|
57
- format.html { redirect_to params[:return_to].presence || "/" }
58
- format.json { render json: { status: "signed_out" } }
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
- provider_name = (params[:provider] || :google).to_sym
70
- Shakha::Providers.resolve(provider_name)
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.find_or_create_by!(
85
+ user = Shakha::User.find_or_initialize_by(
75
86
  provider: provider_name.to_s,
76
87
  uid: identity[:uid]
77
- ) do |user|
78
- user.client = find_or_create_client
79
- user.email = identity[:email]
80
- user.name = identity[:name]
81
- user.picture = identity[:picture]
82
- end
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
- existing["token"] = session_record.token
108
- existing["expires_at"] = session_record.expires_at.iso8601
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