shakha 0.3.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 +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +174 -96
- data/SECURITY.md +56 -0
- data/app/controllers/shakha/application_controller.rb +2 -3
- data/app/controllers/shakha/auth_controller.rb +52 -32
- data/app/controllers/shakha/session_controller.rb +13 -0
- data/app/controllers/shakha/sessions_controller.rb +33 -0
- data/app/models/shakha/session.rb +31 -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 +103 -0
- data/lib/generators/shakha/install/templates/create_shakha_tables.rb.erb +27 -0
- data/lib/generators/shakha/install/templates/shakha.rb.erb +42 -0
- data/lib/shakha/config.rb +6 -2
- data/lib/shakha/config_validator.rb +11 -2
- data/lib/shakha/controller_helpers.rb +6 -4
- data/lib/shakha/engine.rb +12 -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/providers.rb +23 -3
- data/lib/shakha/rate_limiter.rb +5 -5
- data/lib/shakha/version.rb +2 -2
- data/lib/shakha.rb +2 -1
- metadata +31 -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
|
@@ -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,26 +36,34 @@ 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
|
-
|
|
46
|
+
ActiveSupport::Notifications.instrument(
|
|
47
|
+
"shakha.sign_in",
|
|
48
|
+
user_id: user.id, provider: provider.provider_name, ip: request.remote_ip
|
|
49
|
+
)
|
|
50
|
+
redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
|
|
47
51
|
|
|
48
52
|
rescue PKCEError, OAuthError => e
|
|
49
53
|
handle_auth_failure(e, pkce_result)
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def destroy
|
|
53
|
-
current_session
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
if request.format.json?
|
|
64
|
+
render json: { status: "signed_out" }
|
|
65
|
+
else
|
|
66
|
+
redirect_to params[:return_to].presence || "/"
|
|
59
67
|
end
|
|
60
68
|
end
|
|
61
69
|
|
|
@@ -66,26 +74,35 @@ module Shakha
|
|
|
66
74
|
private
|
|
67
75
|
|
|
68
76
|
def resolve_provider
|
|
69
|
-
|
|
70
|
-
Shakha
|
|
77
|
+
name = (params[:provider] || :google).to_sym
|
|
78
|
+
raise ProviderNotFound, name.to_s unless Shakha.config.providers.include?(name)
|
|
79
|
+
|
|
80
|
+
Shakha::Providers.resolve(name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Built from the engine's actual mount point, so Shakha works no matter
|
|
84
|
+
# where the host app mounts it.
|
|
85
|
+
def callback_redirect_uri(provider)
|
|
86
|
+
origin = URI.parse(Shakha.config.app_origin)
|
|
87
|
+
callback_url(provider: provider.provider_name,
|
|
88
|
+
host: origin.host, port: origin.port, protocol: origin.scheme)
|
|
71
89
|
end
|
|
72
90
|
|
|
73
91
|
def find_or_create_user(provider_name, identity)
|
|
74
|
-
Shakha::User.
|
|
92
|
+
user = Shakha::User.find_or_initialize_by(
|
|
75
93
|
provider: provider_name.to_s,
|
|
76
94
|
uid: identity[:uid]
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
)
|
|
96
|
+
user.email = identity[:email]
|
|
97
|
+
user.name = identity[:name]
|
|
98
|
+
user.picture = identity[:picture]
|
|
99
|
+
user.save!
|
|
100
|
+
user
|
|
83
101
|
end
|
|
84
102
|
|
|
85
103
|
def create_session(user)
|
|
86
104
|
Shakha::Session.create!(
|
|
87
105
|
user: user,
|
|
88
|
-
client: find_or_create_client,
|
|
89
106
|
ip_address: request.remote_ip,
|
|
90
107
|
user_agent: request.user_agent
|
|
91
108
|
)
|
|
@@ -104,19 +121,30 @@ module Shakha
|
|
|
104
121
|
def build_return_url(return_to, session_record)
|
|
105
122
|
uri = URI.parse(return_to || "/")
|
|
106
123
|
existing = URI.decode_www_form(uri.query || "").to_h
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
|
|
125
|
+
if Shakha.config.redirect_token_delivery == :token
|
|
126
|
+
existing["token"] = session_record.token
|
|
127
|
+
existing["expires_at"] = session_record.expires_at.iso8601
|
|
128
|
+
else
|
|
129
|
+
existing["code"] = session_record.generate_exchange_code!
|
|
130
|
+
end
|
|
131
|
+
|
|
109
132
|
uri.query = URI.encode_www_form(existing)
|
|
110
133
|
uri.to_s
|
|
111
134
|
end
|
|
112
135
|
|
|
113
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
|
+
)
|
|
114
141
|
return_to = pkce_result&.dig(:return_to) || "/"
|
|
115
142
|
|
|
116
143
|
if request.format.json? || api_request?
|
|
117
144
|
render json: { error: user_facing_error(exception) }, status: :unauthorized
|
|
118
145
|
else
|
|
119
|
-
redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}"
|
|
146
|
+
redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}",
|
|
147
|
+
allow_other_host: true
|
|
120
148
|
end
|
|
121
149
|
end
|
|
122
150
|
|
|
@@ -145,14 +173,6 @@ module Shakha
|
|
|
145
173
|
Shakha.config.allowed_redirect_origins&.include?(origin) || false
|
|
146
174
|
end
|
|
147
175
|
|
|
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
176
|
def user_facing_error(exception)
|
|
157
177
|
case exception
|
|
158
178
|
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
|
|
@@ -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
|
|
@@ -5,12 +5,17 @@ module Shakha
|
|
|
5
5
|
self.table_name = "shakha_sessions"
|
|
6
6
|
|
|
7
7
|
belongs_to :user, class_name: "Shakha::User", optional: true
|
|
8
|
-
belongs_to :client, class_name: "Shakha::Client"
|
|
9
8
|
|
|
10
9
|
before_create :generate_token
|
|
11
10
|
|
|
12
11
|
scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
|
|
13
12
|
|
|
13
|
+
def self.for(user)
|
|
14
|
+
active.where(user: user).order(created_at: :desc, id: :desc)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
EXCHANGE_CODE_TTL = 60.seconds
|
|
18
|
+
|
|
14
19
|
def expired?
|
|
15
20
|
created_at < Shakha.config.session_lifetime.ago
|
|
16
21
|
end
|
|
@@ -19,10 +24,34 @@ module Shakha
|
|
|
19
24
|
created_at + Shakha.config.session_lifetime
|
|
20
25
|
end
|
|
21
26
|
|
|
27
|
+
# Issues a short-lived, single-use code the frontend swaps for the session
|
|
28
|
+
# token, so the token itself never travels in a redirect URL.
|
|
29
|
+
def generate_exchange_code!
|
|
30
|
+
update_columns(
|
|
31
|
+
exchange_code: SecureRandom.urlsafe_base64(32),
|
|
32
|
+
exchange_code_expires_at: EXCHANGE_CODE_TTL.from_now
|
|
33
|
+
)
|
|
34
|
+
exchange_code
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Redeems a code for its session, atomically clearing it so a code can be
|
|
38
|
+
# used at most once even under concurrent requests.
|
|
39
|
+
def self.exchange(code)
|
|
40
|
+
return nil if code.blank?
|
|
41
|
+
|
|
42
|
+
session = active.where(exchange_code: code)
|
|
43
|
+
.where("exchange_code_expires_at > ?", Time.current).first
|
|
44
|
+
return nil unless session
|
|
45
|
+
|
|
46
|
+
claimed = where(id: session.id).where.not(exchange_code: nil)
|
|
47
|
+
.update_all(exchange_code: nil, exchange_code_expires_at: nil)
|
|
48
|
+
claimed == 1 ? session : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
22
51
|
private
|
|
23
52
|
|
|
24
53
|
def generate_token
|
|
25
54
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
26
55
|
end
|
|
27
56
|
end
|
|
28
|
-
end
|
|
57
|
+
end
|
data/app/models/shakha/user.rb
CHANGED
|
@@ -4,12 +4,10 @@ module Shakha
|
|
|
4
4
|
class User < ::ApplicationRecord
|
|
5
5
|
self.table_name = "shakha_users"
|
|
6
6
|
|
|
7
|
-
belongs_to :client, class_name: "Shakha::Client"
|
|
8
7
|
has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
|
|
9
8
|
|
|
10
9
|
validates :provider, presence: true
|
|
11
10
|
validates :uid, presence: true
|
|
12
11
|
validates :uid, uniqueness: { scope: :provider }
|
|
13
|
-
validates :email, uniqueness: { scope: :client_id }, allow_blank: true
|
|
14
12
|
end
|
|
15
|
-
end
|
|
13
|
+
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<% content_for :title, "Sign in — #{
|
|
1
|
+
<% content_for :title, "Sign in — #{Shakha.config.app_name}" %>
|
|
2
2
|
|
|
3
3
|
<div class="sh-layout sh-animate-fade">
|
|
4
4
|
<div class="sh-card sh-animate-slide">
|
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
<div class="sh-brand">
|
|
7
7
|
<div class="sh-brand__icon">S</div>
|
|
8
8
|
<div>
|
|
9
|
-
<div class="sh-brand__name"><%=
|
|
9
|
+
<div class="sh-brand__name"><%= Shakha.config.app_name %></div>
|
|
10
10
|
<div class="sh-brand__subtitle">Secure authentication</div>
|
|
11
11
|
</div>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<h1 class="sh-heading sh-heading--center">Welcome back</h1>
|
|
15
|
-
<p class="sh-subheading">Sign in to continue to <%=
|
|
15
|
+
<p class="sh-subheading">Sign in to continue to <%= Shakha.config.app_name %></p>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<div class="sh-card__body">
|
|
19
19
|
<% @providers.each do |provider| %>
|
|
20
|
-
<%= link_to shakha
|
|
20
|
+
<%= link_to "/auth/shakha/#{provider}",
|
|
21
21
|
class: "sh-btn sh-btn--#{provider}",
|
|
22
22
|
data: { turbo: false } do %>
|
|
23
23
|
Continue with <%= provider.to_s.titleize %>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title><%= yield(:title).presence || "Shakha" %></title>
|
|
7
|
-
|
|
7
|
+
<style><%= Shakha::Engine.root.join("app/assets/stylesheets/shakha.css").read.html_safe %></style>
|
|
8
8
|
<%= csrf_meta_tags %>
|
|
9
9
|
<meta name="theme-color" content="#0f0f23" media="(prefers-color-scheme: dark)">
|
|
10
10
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
BUILT_IN_PROVIDERS = %w[google github].freeze
|
|
12
|
+
|
|
13
|
+
desc "Installs Shakha — headless OAuth broker for Rails"
|
|
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
|
+
|
|
24
|
+
def copy_migration
|
|
25
|
+
migration_template "create_shakha_tables.rb.erb", "db/migrate/create_shakha_tables.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def copy_initializer
|
|
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
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inject_application_controller
|
|
39
|
+
path = "app/controllers/application_controller.rb"
|
|
40
|
+
full_path = File.join(destination_root, path)
|
|
41
|
+
return unless File.exist?(full_path)
|
|
42
|
+
return if File.read(full_path).include?("Shakha::ControllerHelpers")
|
|
43
|
+
|
|
44
|
+
inject_into_class path, "ApplicationController", " include Shakha::ControllerHelpers\n"
|
|
45
|
+
say_status :insert, "ApplicationController -> include Shakha::ControllerHelpers", :green
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enable_cookies_for_api_mode
|
|
49
|
+
return unless api_only_app?
|
|
50
|
+
return if File.read(File.join(destination_root, "config/application.rb")).include?("ActionDispatch::Cookies")
|
|
51
|
+
|
|
52
|
+
application "config.middleware.use ActionDispatch::Cookies"
|
|
53
|
+
say_status :insert, "config/application.rb -> ActionDispatch::Cookies (API mode)", :green
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def print_post_install
|
|
57
|
+
origin = Shakha.config.app_origin || "http://localhost:3000"
|
|
58
|
+
|
|
59
|
+
say ""
|
|
60
|
+
say " Shakha installed!", :green
|
|
61
|
+
say " #{'─' * 50}", :green
|
|
62
|
+
say ""
|
|
63
|
+
say " 1. Set environment variables:", :yellow
|
|
64
|
+
providers.each do |provider|
|
|
65
|
+
say " #{provider.upcase}_CLIENT_ID / #{provider.upcase}_CLIENT_SECRET", :cyan
|
|
66
|
+
end
|
|
67
|
+
say ""
|
|
68
|
+
say " 2. For SPA: set ALLOWED_REDIRECT_ORIGINS", :yellow
|
|
69
|
+
say ""
|
|
70
|
+
say " 3. Run migrations:", :yellow
|
|
71
|
+
say " bin/rails db:migrate", :cyan
|
|
72
|
+
say ""
|
|
73
|
+
say " 4. OAuth app redirect / callback URLs:", :yellow
|
|
74
|
+
providers.each do |provider|
|
|
75
|
+
say " #{origin}/auth/shakha/#{provider}/callback", :cyan
|
|
76
|
+
end
|
|
77
|
+
say " #{'─' * 50}", :green
|
|
78
|
+
say ""
|
|
79
|
+
say " Tell your frontend dev:", :cyan
|
|
80
|
+
say " Sign in: #{origin}/auth/shakha/#{providers.first}"
|
|
81
|
+
say " Session: GET #{origin}/auth/shakha/session"
|
|
82
|
+
say " Auth: Authorization: Bearer <token>"
|
|
83
|
+
say " Sign out: DELETE #{origin}/auth/shakha/sign_out"
|
|
84
|
+
say ""
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def providers
|
|
90
|
+
@providers ||= options[:providers].to_s.split(",").map { |p| p.strip.downcase }.reject(&:empty?).uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def migration_version
|
|
94
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def api_only_app?
|
|
98
|
+
path = File.join(destination_root, "config/application.rb")
|
|
99
|
+
File.exist?(path) && File.read(path).include?("config.api_only = true")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class CreateShakhaTables < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :shakha_users do |t|
|
|
4
|
+
t.string :provider, null: false
|
|
5
|
+
t.string :uid, null: false
|
|
6
|
+
t.string :email
|
|
7
|
+
t.string :name
|
|
8
|
+
t.string :picture
|
|
9
|
+
t.timestamps
|
|
10
|
+
t.index [:provider, :uid], unique: true
|
|
11
|
+
t.index :email
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
create_table :shakha_sessions do |t|
|
|
15
|
+
t.references :user, foreign_key: { to_table: :shakha_users }
|
|
16
|
+
t.string :token, null: false
|
|
17
|
+
t.string :exchange_code
|
|
18
|
+
t.datetime :exchange_code_expires_at
|
|
19
|
+
t.string :ip_address
|
|
20
|
+
t.string :user_agent
|
|
21
|
+
t.timestamps
|
|
22
|
+
t.index :token, unique: true
|
|
23
|
+
t.index :exchange_code, unique: true
|
|
24
|
+
t.index :created_at
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Shakha.setup do |config|
|
|
4
|
+
# Your Rails app's origin
|
|
5
|
+
config.app_origin = ENV.fetch("APP_ORIGIN", "http://localhost:3000")
|
|
6
|
+
|
|
7
|
+
# Name shown on the built-in sign-in page (Rails monolith flow)
|
|
8
|
+
# config.app_name = "My App"
|
|
9
|
+
|
|
10
|
+
# Allowed frontend origins for the SPA redirect
|
|
11
|
+
config.allowed_redirect_origins = ENV.fetch("ALLOWED_REDIRECT_ORIGINS", "").split(",")
|
|
12
|
+
|
|
13
|
+
# How the session token reaches the frontend after OAuth (default:
|
|
14
|
+
# :exchange_code). With :exchange_code the callback redirects with a
|
|
15
|
+
# one-time ?code=..., which the frontend swaps for the token:
|
|
16
|
+
# POST /auth/shakha/session/exchange { "code": "..." }
|
|
17
|
+
# -> { "token": "...", "expires_at": "..." }
|
|
18
|
+
# Set :token to put the token directly in the redirect URL instead
|
|
19
|
+
# (simpler, but the token is exposed in history/logs/Referer).
|
|
20
|
+
# config.redirect_token_delivery = :token
|
|
21
|
+
|
|
22
|
+
<% if providers.include?("google") -%>
|
|
23
|
+
# Google OAuth
|
|
24
|
+
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
25
|
+
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
26
|
+
|
|
27
|
+
<% end -%>
|
|
28
|
+
<% if providers.include?("github") -%>
|
|
29
|
+
# GitHub OAuth
|
|
30
|
+
config.github_client_id = ENV["GITHUB_CLIENT_ID"]
|
|
31
|
+
config.github_client_secret = ENV["GITHUB_CLIENT_SECRET"]
|
|
32
|
+
|
|
33
|
+
<% end -%>
|
|
34
|
+
# Enabled providers
|
|
35
|
+
config.providers = [ <%= providers.map { |p| ":#{p}" }.join(", ") %> ]
|
|
36
|
+
|
|
37
|
+
# Session lifetime (default: 30 days)
|
|
38
|
+
# config.session_lifetime = 30.days
|
|
39
|
+
|
|
40
|
+
# Rate limiting (default: false)
|
|
41
|
+
# config.rate_limiting_enabled = true
|
|
42
|
+
end
|
data/lib/shakha/config.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Shakha
|
|
4
4
|
class Config
|
|
5
5
|
attr_accessor :app_origin,
|
|
6
|
+
:app_name,
|
|
6
7
|
:google_client_id,
|
|
7
8
|
:google_client_secret,
|
|
8
9
|
:github_client_id,
|
|
@@ -10,12 +11,15 @@ module Shakha
|
|
|
10
11
|
:providers,
|
|
11
12
|
:session_lifetime,
|
|
12
13
|
:rate_limiting_enabled,
|
|
13
|
-
:allowed_redirect_origins
|
|
14
|
+
:allowed_redirect_origins,
|
|
15
|
+
:redirect_token_delivery
|
|
14
16
|
|
|
15
17
|
def initialize
|
|
18
|
+
@app_name = "Shakha"
|
|
16
19
|
@session_lifetime = 30.days
|
|
17
20
|
@rate_limiting_enabled = false
|
|
18
|
-
@providers = [:google]
|
|
21
|
+
@providers = [ :google ]
|
|
22
|
+
@redirect_token_delivery = :exchange_code
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
@@ -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
|
-
|
|
10
|
-
|
|
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(', ')}"
|
|
@@ -7,7 +7,7 @@ module Shakha
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
helper_method :
|
|
10
|
+
helper_method :current_session, :current_user, :signed_in? if respond_to?(:helper_method)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
private
|
|
@@ -28,13 +28,15 @@ module Shakha
|
|
|
28
28
|
def authenticate!
|
|
29
29
|
return if signed_in?
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
if request.format.json?
|
|
32
|
+
render json: { error: "Authentication required" }, status: :unauthorized
|
|
33
|
+
else
|
|
34
|
+
redirect_to "/auth/shakha?return_to=#{CGI.escape(request.fullpath)}"
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def find_session_from_cookie
|
|
39
|
+
return unless respond_to?(:cookies)
|
|
38
40
|
token = cookies.encrypted[:shakha_session_token]
|
|
39
41
|
return unless token
|
|
40
42
|
Shakha::Session.active.find_by(token: token)
|
data/lib/shakha/engine.rb
CHANGED
|
@@ -11,13 +11,18 @@ module Shakha
|
|
|
11
11
|
routes do
|
|
12
12
|
root to: "auth#new"
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
get "
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
# Static routes MUST come before dynamic :provider routes
|
|
15
|
+
get "session" => "session#show"
|
|
16
|
+
get "session/check" => "session#check"
|
|
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
|
|
20
|
+
delete "sign_out" => "auth#destroy"
|
|
21
|
+
get "error" => "auth#error"
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
get
|
|
23
|
+
# Dynamic provider routes
|
|
24
|
+
get ":provider" => "auth#authorize", as: :authorize
|
|
25
|
+
get ":provider/callback" => "auth#callback", as: :callback
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
|
-
end
|
|
28
|
+
end
|
data/lib/shakha/error_handler.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Shakha
|
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
10
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
11
|
+
rescue_from Shakha::ProviderNotFound, with: :provider_not_found
|
|
11
12
|
rescue_from Shakha::PKCEError, with: :bad_request
|
|
12
13
|
rescue_from Shakha::OAuthError, with: :bad_gateway
|
|
13
14
|
end
|
|
@@ -18,6 +19,10 @@ module Shakha
|
|
|
18
19
|
render json: { error: exception.message }, status: :not_found
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def provider_not_found(_exception)
|
|
23
|
+
render json: { error: "Unknown provider" }, status: :not_found
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def unauthorized(exception)
|
|
22
27
|
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
23
28
|
end
|
|
@@ -31,4 +36,4 @@ module Shakha
|
|
|
31
36
|
render json: { error: "Authentication service unavailable" }, status: :bad_gateway
|
|
32
37
|
end
|
|
33
38
|
end
|
|
34
|
-
end
|
|
39
|
+
end
|
data/lib/shakha/pkce.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Shakha
|
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def generate_code_verifier
|
|
17
|
-
SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH,
|
|
17
|
+
SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH, false)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def generate_code_challenge(verifier)
|
|
@@ -32,7 +32,9 @@ module Shakha
|
|
|
32
32
|
challenge = PKCEMixin.generate_code_challenge(verifier)
|
|
33
33
|
state = SecureRandom.urlsafe_base64(32)
|
|
34
34
|
nonce = SecureRandom.urlsafe_base64(32)
|
|
35
|
-
|
|
35
|
+
# sanitize_return_to is provided by the including controller (AuthController);
|
|
36
|
+
# validating here means the stored return_to is always safe to redirect to.
|
|
37
|
+
return_to = sanitize_return_to(params[:return_to])
|
|
36
38
|
|
|
37
39
|
pkce_record = {
|
|
38
40
|
verifier: verifier,
|
|
@@ -81,5 +83,4 @@ module Shakha
|
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
class PKCEError < StandardError; end
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
+
end
|