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
|
@@ -5,12 +5,13 @@ 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
|
+
EXCHANGE_CODE_TTL = 60.seconds
|
|
14
|
+
|
|
14
15
|
def expired?
|
|
15
16
|
created_at < Shakha.config.session_lifetime.ago
|
|
16
17
|
end
|
|
@@ -19,10 +20,34 @@ module Shakha
|
|
|
19
20
|
created_at + Shakha.config.session_lifetime
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
# Issues a short-lived, single-use code the frontend swaps for the session
|
|
24
|
+
# token, so the token itself never travels in a redirect URL.
|
|
25
|
+
def generate_exchange_code!
|
|
26
|
+
update_columns(
|
|
27
|
+
exchange_code: SecureRandom.urlsafe_base64(32),
|
|
28
|
+
exchange_code_expires_at: EXCHANGE_CODE_TTL.from_now
|
|
29
|
+
)
|
|
30
|
+
exchange_code
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Redeems a code for its session, atomically clearing it so a code can be
|
|
34
|
+
# used at most once even under concurrent requests.
|
|
35
|
+
def self.exchange(code)
|
|
36
|
+
return nil if code.blank?
|
|
37
|
+
|
|
38
|
+
session = active.where(exchange_code: code)
|
|
39
|
+
.where("exchange_code_expires_at > ?", Time.current).first
|
|
40
|
+
return nil unless session
|
|
41
|
+
|
|
42
|
+
claimed = where(id: session.id).where.not(exchange_code: nil)
|
|
43
|
+
.update_all(exchange_code: nil, exchange_code_expires_at: nil)
|
|
44
|
+
claimed == 1 ? session : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
22
47
|
private
|
|
23
48
|
|
|
24
49
|
def generate_token
|
|
25
50
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
26
51
|
end
|
|
27
52
|
end
|
|
28
|
-
end
|
|
53
|
+
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,84 @@
|
|
|
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
|
+
desc "Installs Shakha — headless OAuth broker for Rails"
|
|
12
|
+
|
|
13
|
+
def copy_migration
|
|
14
|
+
migration_template "create_shakha_tables.rb.erb", "db/migrate/create_shakha_tables.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_initializer
|
|
18
|
+
template "shakha.rb.erb", "config/initializers/shakha.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def inject_application_controller
|
|
22
|
+
path = "app/controllers/application_controller.rb"
|
|
23
|
+
full_path = File.join(destination_root, path)
|
|
24
|
+
return unless File.exist?(full_path)
|
|
25
|
+
return if File.read(full_path).include?("Shakha::ControllerHelpers")
|
|
26
|
+
|
|
27
|
+
inject_into_class path, "ApplicationController", " include Shakha::ControllerHelpers\n"
|
|
28
|
+
say_status :insert, "ApplicationController -> include Shakha::ControllerHelpers", :green
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enable_cookies_for_api_mode
|
|
32
|
+
return unless api_only_app?
|
|
33
|
+
|
|
34
|
+
application "config.middleware.use ActionDispatch::Cookies"
|
|
35
|
+
say_status :insert, "config/application.rb -> ActionDispatch::Cookies (API mode)", :green
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_post_install
|
|
39
|
+
origin = Shakha.config.app_origin || "http://localhost:3000"
|
|
40
|
+
|
|
41
|
+
say ""
|
|
42
|
+
say " Shakha installed!", :green
|
|
43
|
+
say " #{'─' * 50}", :green
|
|
44
|
+
say ""
|
|
45
|
+
say " 1. Set environment variables:", :yellow
|
|
46
|
+
say " GOOGLE_CLIENT_ID", :cyan
|
|
47
|
+
say " GOOGLE_CLIENT_SECRET", :cyan
|
|
48
|
+
say ""
|
|
49
|
+
say " 2. (Optional) GitHub:", :yellow
|
|
50
|
+
say " GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET", :cyan
|
|
51
|
+
say ""
|
|
52
|
+
say " 3. For SPA: set ALLOWED_REDIRECT_ORIGINS", :yellow
|
|
53
|
+
say ""
|
|
54
|
+
say " 4. Run migrations:", :yellow
|
|
55
|
+
say " bin/rails db:migrate", :cyan
|
|
56
|
+
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
|
|
62
|
+
say " #{'─' * 50}", :green
|
|
63
|
+
say ""
|
|
64
|
+
say " Tell your frontend dev:", :cyan
|
|
65
|
+
say " Sign in: #{origin}/auth/shakha/google"
|
|
66
|
+
say " Session: GET #{origin}/auth/shakha/session"
|
|
67
|
+
say " Auth: Authorization: Bearer <token>"
|
|
68
|
+
say " Sign out: DELETE #{origin}/auth/shakha/sign_out"
|
|
69
|
+
say ""
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def migration_version
|
|
75
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def api_only_app?
|
|
79
|
+
path = File.join(destination_root, "config/application.rb")
|
|
80
|
+
File.exist?(path) && File.read(path).include?("config.api_only = true")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
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,39 @@
|
|
|
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
|
+
# Google OAuth (required)
|
|
23
|
+
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
24
|
+
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
25
|
+
|
|
26
|
+
# GitHub OAuth (optional — remove from providers if unused)
|
|
27
|
+
config.github_client_id = ENV["GITHUB_CLIENT_ID"]
|
|
28
|
+
config.github_client_secret = ENV["GITHUB_CLIENT_SECRET"]
|
|
29
|
+
|
|
30
|
+
# Enabled providers
|
|
31
|
+
# config.providers = [:google] # Google only
|
|
32
|
+
config.providers = [:google, :github] # Both
|
|
33
|
+
|
|
34
|
+
# Session lifetime (default: 30 days)
|
|
35
|
+
# config.session_lifetime = 30.days
|
|
36
|
+
|
|
37
|
+
# Rate limiting (default: false)
|
|
38
|
+
# config.rate_limiting_enabled = true
|
|
39
|
+
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
|
|
@@ -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,16 @@ 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
|
+
delete "sign_out" => "auth#destroy"
|
|
19
|
+
get "error" => "auth#error"
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
get
|
|
21
|
+
# Dynamic provider routes
|
|
22
|
+
get ":provider" => "auth#authorize", as: :authorize
|
|
23
|
+
get ":provider/callback" => "auth#callback", as: :callback
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
|
-
end
|
|
26
|
+
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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Shakha
|
|
4
4
|
module Providers
|
|
5
5
|
class Base
|
|
6
|
-
def authorize_url(state:, code_challenge:, redirect_uri:)
|
|
6
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
7
7
|
raise NotImplementedError
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ module Shakha
|
|
|
11
11
|
raise NotImplementedError
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def identity_from_response(token_response)
|
|
14
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
15
15
|
raise NotImplementedError
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -14,7 +14,9 @@ module Shakha
|
|
|
14
14
|
:github
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# GitHub OAuth has no nonce concept; the keyword is accepted for a
|
|
18
|
+
# uniform provider interface and ignored.
|
|
19
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
18
20
|
params = {
|
|
19
21
|
client_id: Shakha.config.github_client_id,
|
|
20
22
|
redirect_uri: redirect_uri,
|
|
@@ -36,7 +38,7 @@ module Shakha
|
|
|
36
38
|
JSON.parse(response.body)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
def identity_from_response(token_response)
|
|
41
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
40
42
|
access_token = token_response["access_token"]
|
|
41
43
|
raise OAuthError, "No access_token received" unless access_token
|
|
42
44
|
|
|
@@ -13,7 +13,7 @@ module Shakha
|
|
|
13
13
|
:google
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def authorize_url(state:, code_challenge:, redirect_uri:)
|
|
16
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
17
17
|
params = {
|
|
18
18
|
client_id: Shakha.config.google_client_id,
|
|
19
19
|
redirect_uri: redirect_uri,
|
|
@@ -24,7 +24,7 @@ module Shakha
|
|
|
24
24
|
state: state,
|
|
25
25
|
access_type: "offline",
|
|
26
26
|
prompt: "consent",
|
|
27
|
-
nonce:
|
|
27
|
+
nonce: nonce
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
"#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
|
|
@@ -43,11 +43,15 @@ module Shakha
|
|
|
43
43
|
JSON.parse(response.body)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def identity_from_response(token_response)
|
|
46
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
47
47
|
id_token = token_response["id_token"]
|
|
48
48
|
raise OAuthError, "No id_token received" unless id_token
|
|
49
49
|
|
|
50
|
+
# Signature is not re-verified: the token comes directly from Google's
|
|
51
|
+
# token endpoint over a TLS connection we initiated, so its provenance
|
|
52
|
+
# is the transport. The claims below still need checking.
|
|
50
53
|
payload = JWT.decode(id_token, nil, false)[0]
|
|
54
|
+
verify_claims!(payload, expected_nonce)
|
|
51
55
|
|
|
52
56
|
{
|
|
53
57
|
provider: :google,
|
|
@@ -64,6 +68,25 @@ module Shakha
|
|
|
64
68
|
|
|
65
69
|
private
|
|
66
70
|
|
|
71
|
+
VALID_ISSUERS = [ "https://accounts.google.com", "accounts.google.com" ].freeze
|
|
72
|
+
|
|
73
|
+
def verify_claims!(payload, expected_nonce)
|
|
74
|
+
unless VALID_ISSUERS.include?(payload["iss"])
|
|
75
|
+
raise OAuthError, "ID token issuer mismatch"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless payload["aud"] == Shakha.config.google_client_id
|
|
79
|
+
raise OAuthError, "ID token audience mismatch"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise OAuthError, "ID token expired" if payload["exp"].to_i <= Time.now.to_i
|
|
83
|
+
|
|
84
|
+
if expected_nonce.present? &&
|
|
85
|
+
!ActiveSupport::SecurityUtils.secure_compare(payload["nonce"].to_s, expected_nonce)
|
|
86
|
+
raise OAuthError, "ID token nonce mismatch"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
def http_post(url, body)
|
|
68
91
|
uri = URI.parse(url)
|
|
69
92
|
http = Net::HTTP.new(uri.host, uri.port)
|
data/lib/shakha/rate_limiter.rb
CHANGED
|
@@ -5,8 +5,8 @@ module Shakha
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
|
-
before_action :check_rate_limit_authorize, only: [:authorize]
|
|
9
|
-
before_action :
|
|
8
|
+
before_action :check_rate_limit_authorize, only: [ :authorize ]
|
|
9
|
+
before_action :check_rate_limit_callback, only: [ :callback ]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
private
|
|
@@ -15,15 +15,15 @@ module Shakha
|
|
|
15
15
|
check_rate_limit("authorize", max: 20, period: 1.minute)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
19
|
-
check_rate_limit("
|
|
18
|
+
def check_rate_limit_callback
|
|
19
|
+
check_rate_limit("callback", max: 10, period: 1.minute)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def check_rate_limit(key, max:, period:)
|
|
23
23
|
return unless Shakha.config.rate_limiting_enabled
|
|
24
24
|
|
|
25
25
|
cache_key = "shakha-rate:#{key}:#{request.remote_ip}"
|
|
26
|
-
count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds)
|
|
26
|
+
count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds) || 1
|
|
27
27
|
|
|
28
28
|
if count > max
|
|
29
29
|
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
|
data/lib/shakha/version.rb
CHANGED
data/lib/shakha.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shakha
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: jwt
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.10.3
|
|
19
|
+
- - "<"
|
|
17
20
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
21
|
+
version: '3'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 2.10.3
|
|
29
|
+
- - "<"
|
|
24
30
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
31
|
+
version: '3'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: activesupport
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -64,25 +70,26 @@ dependencies:
|
|
|
64
70
|
- !ruby/object:Gem::Version
|
|
65
71
|
version: '10'
|
|
66
72
|
description: |
|
|
67
|
-
Shakha is a headless
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
Shakha is a headless OAuth broker engine for Rails APIs and monoliths.
|
|
74
|
+
Your frontend does a single redirect; Shakha runs the OAuth dance
|
|
75
|
+
(PKCE, state) against Google or GitHub, stores a revocable
|
|
76
|
+
database-backed session, and redirects back with a session token usable
|
|
77
|
+
via encrypted cookie or Authorization: Bearer. No JWTs issued, no Redis,
|
|
78
|
+
no frontend SDK. Pre-1.0: APIs and security posture still evolving.
|
|
73
79
|
email:
|
|
74
|
-
-
|
|
80
|
+
- asratextras77@gmail.com
|
|
75
81
|
executables: []
|
|
76
82
|
extensions: []
|
|
77
83
|
extra_rdoc_files: []
|
|
78
84
|
files:
|
|
85
|
+
- CHANGELOG.md
|
|
79
86
|
- LICENSE.txt
|
|
80
87
|
- README.md
|
|
88
|
+
- SECURITY.md
|
|
81
89
|
- app/assets/stylesheets/shakha.css
|
|
82
90
|
- app/controllers/shakha/application_controller.rb
|
|
83
91
|
- app/controllers/shakha/auth_controller.rb
|
|
84
92
|
- app/controllers/shakha/session_controller.rb
|
|
85
|
-
- app/models/shakha/client.rb
|
|
86
93
|
- app/models/shakha/session.rb
|
|
87
94
|
- app/models/shakha/user.rb
|
|
88
95
|
- app/views/shakha/auth/callback.html.erb
|
|
@@ -90,9 +97,9 @@ files:
|
|
|
90
97
|
- app/views/shakha/auth/new.html.erb
|
|
91
98
|
- app/views/shakha/errors/show.html.erb
|
|
92
99
|
- app/views/shakha/layouts/shakha.html.erb
|
|
93
|
-
- lib/generators/shakha/install_generator.rb
|
|
94
|
-
- lib/generators/shakha/templates/
|
|
95
|
-
- lib/generators/shakha/templates/
|
|
100
|
+
- lib/generators/shakha/install/install_generator.rb
|
|
101
|
+
- lib/generators/shakha/install/templates/create_shakha_tables.rb.erb
|
|
102
|
+
- lib/generators/shakha/install/templates/shakha.rb.erb
|
|
96
103
|
- lib/shakha.rb
|
|
97
104
|
- lib/shakha/config.rb
|
|
98
105
|
- lib/shakha/config_validator.rb
|
|
@@ -106,11 +113,15 @@ files:
|
|
|
106
113
|
- lib/shakha/providers/google.rb
|
|
107
114
|
- lib/shakha/rate_limiter.rb
|
|
108
115
|
- lib/shakha/version.rb
|
|
109
|
-
homepage: https://shakha
|
|
116
|
+
homepage: https://github.com/Asrat77/shakha
|
|
110
117
|
licenses:
|
|
111
118
|
- MIT
|
|
112
119
|
metadata:
|
|
113
|
-
homepage_uri: https://shakha
|
|
120
|
+
homepage_uri: https://github.com/Asrat77/shakha
|
|
121
|
+
source_code_uri: https://github.com/Asrat77/shakha
|
|
122
|
+
changelog_uri: https://github.com/Asrat77/shakha/blob/master/CHANGELOG.md
|
|
123
|
+
bug_tracker_uri: https://github.com/Asrat77/shakha/issues
|
|
124
|
+
rubygems_mfa_required: 'true'
|
|
114
125
|
rdoc_options: []
|
|
115
126
|
require_paths:
|
|
116
127
|
- lib
|
|
@@ -127,5 +138,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
127
138
|
requirements: []
|
|
128
139
|
rubygems_version: 3.6.9
|
|
129
140
|
specification_version: 4
|
|
130
|
-
summary:
|
|
141
|
+
summary: SPA-first OAuth session broker for Rails — one redirect, one token, done
|
|
131
142
|
test_files: []
|
data/app/models/shakha/client.rb
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Shakha
|
|
4
|
-
class Client < ::ApplicationRecord
|
|
5
|
-
self.table_name = "shakha_clients"
|
|
6
|
-
|
|
7
|
-
has_many :sessions, class_name: "Shakha::Session", dependent: :restrict_with_error
|
|
8
|
-
has_many :users, class_name: "Shakha::User", dependent: :nullify
|
|
9
|
-
|
|
10
|
-
validates :origin, presence: true, uniqueness: true
|
|
11
|
-
|
|
12
|
-
def self.find_by_origin!(origin)
|
|
13
|
-
find_by!(origin: URI.parse(origin).origin)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|