shakha 0.8.0 → 0.9.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: 8f67f490e63b0d03cfa0d2abd6b31e68ced7a20e17093b6ccc77f15209b4a2cd
4
- data.tar.gz: 75edc086b7684691be80bc23ed40950d839a1c26b4070edcbe14447153d61995
3
+ metadata.gz: d366a8476de38c1c5ba4a9a1b5cc286b96ef0f99c20e08101c94a4048621a63c
4
+ data.tar.gz: 7a79e8f4493e93a4b1d6e2d9711ad0bbbd9eede57fede0de68ce667d184d82a9
5
5
  SHA512:
6
- metadata.gz: 3797ed65b6d60130ac0afbe7dcb11790cacb4ab46feff2d74125575377a8b633aa290b5db2edf1d61b8fa87f3c66ce3980389424cca5d4d8394b729d67012a20
7
- data.tar.gz: 9cc96c35365fdee1025a878d7abf344f0755e37cd14f94995566723a27f7a978e86532f4065a34ca9eec0b472c1cfd879509ee4f22c6c9faf6948fc5f17e326c
6
+ metadata.gz: 1da077fb10311c211402e86cc09525bc1ac6aabf0d78000952d493314f4b5f47e54d77b7ee00615483f5aefb6c29e1f0732997fa363fcfe84b4856466b0f4234
7
+ data.tar.gz: 17825d89cd29b0a4e63ed1b4a504e4a4c2c5fa91696d77dfccbbedbde1e0ff80ba2ee739f9916f43671a9b71f5345244c73956bfa464087a97eec58a07626ed8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ versions may include breaking changes; these are called out below.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] — 2026-10-03
11
+
12
+ Developer experience and adoption.
13
+
14
+ ### Added
15
+ - `ActiveSupport::Notifications` events: `shakha.sign_in`, `shakha.auth_failure`,
16
+ and `shakha.sign_out`, documented in `docs/instrumentation.md`.
17
+ - `Shakha::Providers.register(name, klass)` for third-party providers, plus a
18
+ provider-writing guide (`docs/providers.md`) with a worked GitLab example.
19
+ - `GET /sessions` lists the current user's active sessions (flagging the
20
+ requesting one) and `DELETE /sessions/:id` revokes one of them.
21
+ `Shakha::Session.for(user)` returns a user's active sessions newest first.
22
+ - Installer `--providers=google,github` option; the initializer only includes
23
+ the chosen providers.
24
+ - `examples/rails-api-react`: a runnable Rails API + Vite/React demo, booted in CI.
25
+
26
+ ### Changed
27
+ - Re-running `rails generate shakha:install` no longer creates a second
28
+ migration, overwrites an existing initializer, or re-adds the cookie
29
+ middleware.
30
+ - Configuration validation now requires credentials only for enabled built-in
31
+ providers. A GitHub-only app no longer needs `GOOGLE_CLIENT_ID`.
32
+
10
33
  ## [0.8.0] — 2026-07-27
11
34
 
12
35
  First public release of the reworked gem.
@@ -94,7 +117,8 @@ installs of the intermediate 0.6.0 to migrate.
94
117
  - Initial prototype: Google OAuth broker with PKCE, database sessions, and a
95
118
  sign-in page.
96
119
 
97
- [Unreleased]: https://github.com/Asrat77/shakha/compare/v0.8.0...HEAD
120
+ [Unreleased]: https://github.com/Asrat77/shakha/compare/v0.9.0...HEAD
121
+ [0.9.0]: https://github.com/Asrat77/shakha/compare/v0.8.0...v0.9.0
98
122
  [0.8.0]: https://github.com/Asrat77/shakha/compare/v0.7.0...v0.8.0
99
123
  [0.7.0]: https://github.com/Asrat77/shakha/compare/v0.6.0...v0.7.0
100
124
  [0.6.0]: https://github.com/Asrat77/shakha/compare/v0.5.0...v0.6.0
data/README.md CHANGED
@@ -53,7 +53,10 @@ bin/rails db:migrate
53
53
 
54
54
  The generator writes a migration and `config/initializers/shakha.rb`, includes
55
55
  `Shakha::ControllerHelpers` in your `ApplicationController`, and — for API-only
56
- apps — adds the cookie middleware Shakha needs.
56
+ apps — adds the cookie middleware Shakha needs. It configures Google and GitHub
57
+ by default; pass `--providers=google` (or `github`) to set up just one. Re-running
58
+ the generator is safe: it won't add a second migration or overwrite your
59
+ initializer.
57
60
 
58
61
  ### Configuration
59
62
 
@@ -103,6 +106,9 @@ useEffect(() => {
103
106
  }, []);
104
107
  ```
105
108
 
109
+ For a complete runnable version (Rails API + Vite/React), see
110
+ [examples/rails-api-react](examples/rails-api-react).
111
+
106
112
  ## Rails monolith usage
107
113
 
108
114
  Shakha ships a minimal sign-in page and sets an encrypted session cookie, so a
@@ -145,6 +151,8 @@ All paths are relative to the mount point (`/auth/shakha` above).
145
151
  | GET | `/session` | cookie or bearer | Current user: `{ user: {...}, session: {...} }` |
146
152
  | GET | `/session/check` | cookie or bearer | Lightweight `{ status: "active" }` / `401 { status: "expired" }` |
147
153
  | DELETE | `/sign_out` | cookie or bearer | Destroy the session |
154
+ | GET | `/sessions` | cookie or bearer | Your active sessions, newest first: `{ sessions: [{ id, ip_address, user_agent, created_at, current }] }` |
155
+ | DELETE | `/sessions/:id` | cookie or bearer | Revoke one of your sessions (`404` if it isn't yours) |
148
156
 
149
157
  ## Configuration reference
150
158
 
@@ -170,6 +178,13 @@ strings in the database — deleting the row revokes access immediately. See
170
178
  [SECURITY.md](SECURITY.md) for the full threat model and how to report a
171
179
  vulnerability.
172
180
 
181
+ ## Instrumentation
182
+
183
+ Shakha emits `shakha.sign_in`, `shakha.auth_failure`, and `shakha.sign_out`
184
+ through `ActiveSupport::Notifications`, so you can build an audit log without
185
+ patching the engine. See [docs/instrumentation.md](docs/instrumentation.md) for
186
+ payloads and examples.
187
+
173
188
  ## How it compares
174
189
 
175
190
  - **OmniAuth** — a Rack middleware for the OAuth handshake; you still build
@@ -184,9 +199,10 @@ vulnerability.
184
199
 
185
200
  A provider implements five methods (`provider_name`, `scopes`, `authorize_url`,
186
201
  `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.
202
+ `{ provider:, uid:, email:, name:, picture: }`, and is registered with
203
+ `Shakha::Providers.register(:gitlab, "GitLabProvider")`. See
204
+ [docs/providers.md](docs/providers.md) for the full contract, a worked GitLab
205
+ example, and a testing pattern.
190
206
 
191
207
  ## Development
192
208
 
@@ -43,6 +43,10 @@ module Shakha
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
+ ActiveSupport::Notifications.instrument(
47
+ "shakha.sign_in",
48
+ user_id: user.id, provider: provider.provider_name, ip: request.remote_ip
49
+ )
46
50
  redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
47
51
 
48
52
  rescue PKCEError, OAuthError => e
@@ -50,7 +54,10 @@ module Shakha
50
54
  end
51
55
 
52
56
  def destroy
53
- current_session&.destroy
57
+ if current_session
58
+ ActiveSupport::Notifications.instrument("shakha.sign_out", user_id: current_session.user_id)
59
+ current_session.destroy
60
+ end
54
61
  cookies.delete(:shakha_session_token)
55
62
 
56
63
  if request.format.json?
@@ -127,6 +134,10 @@ module Shakha
127
134
  end
128
135
 
129
136
  def handle_auth_failure(exception, pkce_result)
137
+ ActiveSupport::Notifications.instrument(
138
+ "shakha.auth_failure",
139
+ provider: params[:provider]&.to_sym, error: exception.class.name
140
+ )
130
141
  return_to = pkce_result&.dig(:return_to) || "/"
131
142
 
132
143
  if request.format.json? || api_request?
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class SessionsController < ApplicationController
5
+ before_action :authenticate!
6
+
7
+ # GET /sessions — the current user's active sessions, newest first.
8
+ def index
9
+ sessions = Shakha::Session.for(current_user).map do |session_record|
10
+ {
11
+ id: session_record.id,
12
+ ip_address: session_record.ip_address,
13
+ user_agent: session_record.user_agent,
14
+ created_at: session_record.created_at.iso8601,
15
+ current: session_record.id == current_session.id
16
+ }
17
+ end
18
+
19
+ render json: { sessions: sessions }
20
+ end
21
+
22
+ # DELETE /sessions/:id — revoke one of the current user's sessions.
23
+ def destroy
24
+ session_record = Shakha::Session.for(current_user).find_by(id: params[:id])
25
+ return render json: { error: "Session not found" }, status: :not_found unless session_record
26
+
27
+ session_record.destroy
28
+ cookies.delete(:shakha_session_token) if session_record.id == current_session.id
29
+
30
+ render json: { status: "revoked" }
31
+ end
32
+ end
33
+ end
@@ -10,6 +10,10 @@ module Shakha
10
10
 
11
11
  scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
12
12
 
13
+ def self.for(user)
14
+ active.where(user: user).order(created_at: :desc, id: :desc)
15
+ end
16
+
13
17
  EXCHANGE_CODE_TTL = 60.seconds
14
18
 
15
19
  def expired?
@@ -8,14 +8,31 @@ module Shakha
8
8
  include ActiveRecord::Generators::Migration
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
+ BUILT_IN_PROVIDERS = %w[google github].freeze
12
+
11
13
  desc "Installs Shakha — headless OAuth broker for Rails"
12
14
 
15
+ class_option :providers, type: :string, default: "google,github",
16
+ desc: "Comma-separated providers to configure (#{BUILT_IN_PROVIDERS.join(', ')})"
17
+
18
+ def validate_providers
19
+ unknown = providers - BUILT_IN_PROVIDERS
20
+ raise Thor::Error, "Unknown provider(s): #{unknown.join(', ')}. Choose from: #{BUILT_IN_PROVIDERS.join(', ')}" if unknown.any?
21
+ raise Thor::Error, "--providers must name at least one provider" if providers.empty?
22
+ end
23
+
13
24
  def copy_migration
14
25
  migration_template "create_shakha_tables.rb.erb", "db/migrate/create_shakha_tables.rb"
15
26
  end
16
27
 
17
28
  def copy_initializer
18
- template "shakha.rb.erb", "config/initializers/shakha.rb"
29
+ path = "config/initializers/shakha.rb"
30
+ if File.exist?(File.join(destination_root, path)) && !options[:force]
31
+ say_status :exist, path, :blue
32
+ return
33
+ end
34
+
35
+ template "shakha.rb.erb", path
19
36
  end
20
37
 
21
38
  def inject_application_controller
@@ -30,6 +47,7 @@ module Shakha
30
47
 
31
48
  def enable_cookies_for_api_mode
32
49
  return unless api_only_app?
50
+ return if File.read(File.join(destination_root, "config/application.rb")).include?("ActionDispatch::Cookies")
33
51
 
34
52
  application "config.middleware.use ActionDispatch::Cookies"
35
53
  say_status :insert, "config/application.rb -> ActionDispatch::Cookies (API mode)", :green
@@ -43,26 +61,23 @@ module Shakha
43
61
  say " #{'─' * 50}", :green
44
62
  say ""
45
63
  say " 1. Set environment variables:", :yellow
46
- say " GOOGLE_CLIENT_ID", :cyan
47
- say " GOOGLE_CLIENT_SECRET", :cyan
64
+ providers.each do |provider|
65
+ say " #{provider.upcase}_CLIENT_ID / #{provider.upcase}_CLIENT_SECRET", :cyan
66
+ end
48
67
  say ""
49
- say " 2. (Optional) GitHub:", :yellow
50
- say " GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET", :cyan
68
+ say " 2. For SPA: set ALLOWED_REDIRECT_ORIGINS", :yellow
51
69
  say ""
52
- say " 3. For SPA: set ALLOWED_REDIRECT_ORIGINS", :yellow
53
- say ""
54
- say " 4. Run migrations:", :yellow
70
+ say " 3. Run migrations:", :yellow
55
71
  say " bin/rails db:migrate", :cyan
56
72
  say ""
57
- say " 5. Google Cloud Console redirect URI:", :yellow
58
- say " #{origin}/auth/shakha/google/callback", :cyan
59
- say ""
60
- say " 6. GitHub OAuth App callback URL:", :yellow
61
- say " #{origin}/auth/shakha/github/callback", :cyan
73
+ say " 4. OAuth app redirect / callback URLs:", :yellow
74
+ providers.each do |provider|
75
+ say " #{origin}/auth/shakha/#{provider}/callback", :cyan
76
+ end
62
77
  say " #{'─' * 50}", :green
63
78
  say ""
64
79
  say " Tell your frontend dev:", :cyan
65
- say " Sign in: #{origin}/auth/shakha/google"
80
+ say " Sign in: #{origin}/auth/shakha/#{providers.first}"
66
81
  say " Session: GET #{origin}/auth/shakha/session"
67
82
  say " Auth: Authorization: Bearer <token>"
68
83
  say " Sign out: DELETE #{origin}/auth/shakha/sign_out"
@@ -71,6 +86,10 @@ module Shakha
71
86
 
72
87
  private
73
88
 
89
+ def providers
90
+ @providers ||= options[:providers].to_s.split(",").map { |p| p.strip.downcase }.reject(&:empty?).uniq
91
+ end
92
+
74
93
  def migration_version
75
94
  "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
76
95
  end
@@ -19,17 +19,20 @@ Shakha.setup do |config|
19
19
  # (simpler, but the token is exposed in history/logs/Referer).
20
20
  # config.redirect_token_delivery = :token
21
21
 
22
- # Google OAuth (required)
22
+ <% if providers.include?("google") -%>
23
+ # Google OAuth
23
24
  config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
24
25
  config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
25
26
 
26
- # GitHub OAuth (optional — remove from providers if unused)
27
+ <% end -%>
28
+ <% if providers.include?("github") -%>
29
+ # GitHub OAuth
27
30
  config.github_client_id = ENV["GITHUB_CLIENT_ID"]
28
31
  config.github_client_secret = ENV["GITHUB_CLIENT_SECRET"]
29
32
 
33
+ <% end -%>
30
34
  # Enabled providers
31
- # config.providers = [:google] # Google only
32
- config.providers = [:google, :github] # Both
35
+ config.providers = [ <%= providers.map { |p| ":#{p}" }.join(", ") %> ]
33
36
 
34
37
  # Session lifetime (default: 30 days)
35
38
  # config.session_lifetime = 30.days
@@ -2,12 +2,21 @@
2
2
 
3
3
  module Shakha
4
4
  module ConfigValidator
5
+ BUILT_IN_CREDENTIALS = {
6
+ google: %i[google_client_id google_client_secret],
7
+ github: %i[github_client_id github_client_secret]
8
+ }.freeze
9
+
5
10
  class << self
6
11
  def validate!(config)
7
12
  missing = []
8
13
  missing << "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?
14
+
15
+ Array(config.providers).each do |provider|
16
+ BUILT_IN_CREDENTIALS.fetch(provider.to_sym, []).each do |attr|
17
+ missing << attr.to_s.upcase unless config.public_send(attr).present?
18
+ end
19
+ end
11
20
 
12
21
  unless missing.empty?
13
22
  message = "Shakha: missing required configuration: #{missing.join(', ')}"
data/lib/shakha/engine.rb CHANGED
@@ -15,6 +15,8 @@ module Shakha
15
15
  get "session" => "session#show"
16
16
  get "session/check" => "session#check"
17
17
  post "session/exchange" => "session#exchange"
18
+ get "sessions" => "sessions#index", defaults: { format: :json }
19
+ delete "sessions/:id" => "sessions#destroy", defaults: { format: :json }, as: :revoke_session
18
20
  delete "sign_out" => "auth#destroy"
19
21
  get "error" => "auth#error"
20
22
 
@@ -11,9 +11,29 @@ module Shakha
11
11
  github: "Shakha::Providers::GitHub"
12
12
  }.freeze
13
13
 
14
- def self.resolve(name)
15
- class_name = PROVIDER_MAP[name.to_sym] || raise(ConfigurationError, "Unknown provider: #{name}")
16
- class_name.constantize.new
14
+ @registry = PROVIDER_MAP.dup
15
+
16
+ class << self
17
+ # Registers a provider under +name+. +klass+ is a Providers::Base
18
+ # subclass or its name as a String (a String survives code reloading in
19
+ # development). Registering an existing name replaces it.
20
+ def register(name, klass)
21
+ @registry[name.to_sym] = klass
22
+ end
23
+
24
+ def registered
25
+ @registry.keys
26
+ end
27
+
28
+ def resolve(name)
29
+ klass = @registry[name.to_sym] || raise(ConfigurationError, "Unknown provider: #{name}")
30
+ klass = klass.constantize if klass.is_a?(String)
31
+ klass.new
32
+ end
33
+
34
+ def reset!
35
+ @registry = PROVIDER_MAP.dup
36
+ end
17
37
  end
18
38
  end
19
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
@@ -90,6 +90,7 @@ files:
90
90
  - app/controllers/shakha/application_controller.rb
91
91
  - app/controllers/shakha/auth_controller.rb
92
92
  - app/controllers/shakha/session_controller.rb
93
+ - app/controllers/shakha/sessions_controller.rb
93
94
  - app/models/shakha/session.rb
94
95
  - app/models/shakha/user.rb
95
96
  - app/views/shakha/auth/callback.html.erb