shakha 0.2.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 -92
- data/SECURITY.md +56 -0
- data/app/controllers/shakha/application_controller.rb +3 -4
- data/app/controllers/shakha/auth_controller.rb +103 -196
- data/app/controllers/shakha/session_controller.rb +27 -59
- data/app/models/shakha/session.rb +27 -7
- data/app/models/shakha/user.rb +4 -8
- data/app/views/shakha/auth/new.html.erb +9 -21
- 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 +10 -30
- data/lib/shakha/config_validator.rb +1 -2
- data/lib/shakha/controller_helpers.rb +16 -33
- data/lib/shakha/engine.rb +10 -17
- data/lib/shakha/error_handler.rb +8 -4
- data/lib/shakha/pkce.rb +5 -4
- data/lib/shakha/providers/base.rb +27 -0
- data/lib/shakha/providers/github.rb +101 -0
- data/lib/shakha/providers/google.rb +113 -0
- data/lib/shakha/providers.rb +19 -0
- data/lib/shakha/rate_limiter.rb +5 -5
- data/lib/shakha/version.rb +2 -2
- data/lib/shakha.rb +4 -29
- metadata +34 -26
- data/app/controllers/shakha/jwks_controller.rb +0 -10
- data/app/controllers/shakha/openid_controller.rb +0 -21
- data/app/models/shakha/client.rb +0 -20
- data/app/views/shakha/auth/sessions.html.erb +0 -66
- 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
- data/lib/shakha/auditable.rb +0 -47
- data/lib/shakha/jwt_handler.rb +0 -127
- data/lib/shakha/middleware.rb +0 -49
- data/lib/shakha/pairwise.rb +0 -26
|
@@ -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,43 +3,23 @@
|
|
|
3
3
|
module Shakha
|
|
4
4
|
class Config
|
|
5
5
|
attr_accessor :app_origin,
|
|
6
|
-
:
|
|
7
|
-
:service_secret,
|
|
6
|
+
:app_name,
|
|
8
7
|
:google_client_id,
|
|
9
8
|
:google_client_secret,
|
|
10
|
-
:
|
|
9
|
+
:github_client_id,
|
|
10
|
+
:github_client_secret,
|
|
11
|
+
:providers,
|
|
11
12
|
:session_lifetime,
|
|
12
|
-
:signing_key,
|
|
13
|
-
:verification_key,
|
|
14
|
-
:key_id,
|
|
15
13
|
:rate_limiting_enabled,
|
|
16
|
-
:allowed_redirect_origins
|
|
14
|
+
:allowed_redirect_origins,
|
|
15
|
+
:redirect_token_delivery
|
|
17
16
|
|
|
18
17
|
def initialize
|
|
18
|
+
@app_name = "Shakha"
|
|
19
19
|
@session_lifetime = 30.days
|
|
20
|
-
@issuer = "https://shakha.dev"
|
|
21
20
|
@rate_limiting_enabled = false
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def embedded?
|
|
25
|
-
service_url.blank?
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def service_base_url
|
|
29
|
-
return app_origin if embedded?
|
|
30
|
-
|
|
31
|
-
service_url.chomp("/")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def client_id
|
|
35
|
-
return @client_id if defined?(@client_id)
|
|
36
|
-
|
|
37
|
-
origin = URI.parse(app_origin).origin
|
|
38
|
-
@client_id = "origin:#{origin}"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def audience
|
|
42
|
-
client_id
|
|
21
|
+
@providers = [ :google ]
|
|
22
|
+
@redirect_token_delivery = :exchange_code
|
|
43
23
|
end
|
|
44
24
|
end
|
|
45
|
-
end
|
|
25
|
+
end
|
|
@@ -5,10 +5,9 @@ module Shakha
|
|
|
5
5
|
class << self
|
|
6
6
|
def validate!(config)
|
|
7
7
|
missing = []
|
|
8
|
-
missing << "
|
|
8
|
+
missing << "APP_ORIGIN" unless config.app_origin.present?
|
|
9
9
|
missing << "GOOGLE_CLIENT_ID" unless config.google_client_id.present?
|
|
10
10
|
missing << "GOOGLE_CLIENT_SECRET" unless config.google_client_secret.present?
|
|
11
|
-
missing << "SHAKHA_SERVICE_SECRET" unless config.service_secret.present?
|
|
12
11
|
|
|
13
12
|
unless missing.empty?
|
|
14
13
|
message = "Shakha: missing required configuration: #{missing.join(', ')}"
|
|
@@ -7,15 +7,14 @@ module Shakha
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
helper_method :current_session, :current_user, :signed_in?
|
|
10
|
+
helper_method :current_session, :current_user, :signed_in? if respond_to?(:helper_method)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def current_session
|
|
16
16
|
return @current_session if defined?(@current_session)
|
|
17
|
-
|
|
18
|
-
@current_session = find_session || authenticate_from_bearer || authenticate_from_cookie
|
|
17
|
+
@current_session = find_session_from_cookie || find_session_from_bearer
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
def current_user
|
|
@@ -29,42 +28,26 @@ module Shakha
|
|
|
29
28
|
def authenticate!
|
|
30
29
|
return if signed_in?
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
payload = Shakha.verify_token(token)
|
|
39
|
-
find_session_by_jti(payload["jti"])
|
|
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)}"
|
|
35
|
+
end
|
|
40
36
|
end
|
|
41
37
|
|
|
42
|
-
def
|
|
43
|
-
|
|
38
|
+
def find_session_from_cookie
|
|
39
|
+
return unless respond_to?(:cookies)
|
|
40
|
+
token = cookies.encrypted[:shakha_session_token]
|
|
41
|
+
return unless token
|
|
42
|
+
Shakha::Session.active.find_by(token: token)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
|
-
def
|
|
47
|
-
pattern = /^Bearer /
|
|
45
|
+
def find_session_from_bearer
|
|
48
46
|
header = request.headers["Authorization"]
|
|
49
|
-
return unless header&.
|
|
50
|
-
|
|
51
|
-
header.gsub(pattern, "")
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def session_token
|
|
55
|
-
request.cookie_jar.encrypted[:shakha_session_token]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def find_session
|
|
59
|
-
return unless (token = session_token)
|
|
47
|
+
return unless header&.start_with?("Bearer ")
|
|
60
48
|
|
|
49
|
+
token = header.delete_prefix("Bearer ")
|
|
61
50
|
Shakha::Session.active.find_by(token: token)
|
|
62
51
|
end
|
|
63
|
-
|
|
64
|
-
def find_session_by_jti(jti)
|
|
65
|
-
return unless jti
|
|
66
|
-
|
|
67
|
-
Shakha::Session.active.find_by(jti: jti)
|
|
68
|
-
end
|
|
69
52
|
end
|
|
70
|
-
end
|
|
53
|
+
end
|
data/lib/shakha/engine.rb
CHANGED
|
@@ -4,30 +4,23 @@ module Shakha
|
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace Shakha
|
|
6
6
|
|
|
7
|
-
config.app_middleware.use Shakha::Middleware
|
|
8
|
-
|
|
9
7
|
config.after_initialize do
|
|
10
8
|
Shakha::ConfigValidator.validate!(Shakha.config)
|
|
11
9
|
end
|
|
12
10
|
|
|
13
|
-
# Engine routes - these should be relative paths
|
|
14
11
|
routes do
|
|
15
12
|
root to: "auth#new"
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
get
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
get "
|
|
23
|
-
get "sessions" => "session#index"
|
|
24
|
-
get "sessions/view" => "session#list"
|
|
25
|
-
post "session/check" => "session#check"
|
|
26
|
-
delete "session" => "session#destroy"
|
|
27
|
-
delete "sessions/:id" => "session#revoke"
|
|
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"
|
|
28
20
|
|
|
29
|
-
|
|
30
|
-
get "
|
|
21
|
+
# Dynamic provider routes
|
|
22
|
+
get ":provider" => "auth#authorize", as: :authorize
|
|
23
|
+
get ":provider/callback" => "auth#callback", as: :callback
|
|
31
24
|
end
|
|
32
25
|
end
|
|
33
|
-
end
|
|
26
|
+
end
|
data/lib/shakha/error_handler.rb
CHANGED
|
@@ -8,9 +8,9 @@ module Shakha
|
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
10
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
11
|
-
rescue_from Shakha::
|
|
11
|
+
rescue_from Shakha::ProviderNotFound, with: :provider_not_found
|
|
12
12
|
rescue_from Shakha::PKCEError, with: :bad_request
|
|
13
|
-
rescue_from Shakha::
|
|
13
|
+
rescue_from Shakha::OAuthError, with: :bad_gateway
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
private
|
|
@@ -19,6 +19,10 @@ module Shakha
|
|
|
19
19
|
render json: { error: exception.message }, status: :not_found
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def provider_not_found(_exception)
|
|
23
|
+
render json: { error: "Unknown provider" }, status: :not_found
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
def unauthorized(exception)
|
|
23
27
|
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
24
28
|
end
|
|
@@ -28,8 +32,8 @@ module Shakha
|
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def bad_gateway(exception)
|
|
31
|
-
Rails.logger.error("[Shakha]
|
|
35
|
+
Rails.logger.error("[Shakha] OAuth error: #{exception.message}")
|
|
32
36
|
render json: { error: "Authentication service unavailable" }, status: :bad_gateway
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
|
-
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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
module Providers
|
|
5
|
+
class Base
|
|
6
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def exchange_code(code:, code_verifier:, redirect_uri:)
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def provider_name
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def scopes
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Shakha
|
|
7
|
+
module Providers
|
|
8
|
+
class GitHub < Base
|
|
9
|
+
AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
|
|
10
|
+
TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
11
|
+
USER_API_URL = "https://api.github.com/user"
|
|
12
|
+
|
|
13
|
+
def provider_name
|
|
14
|
+
:github
|
|
15
|
+
end
|
|
16
|
+
|
|
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)
|
|
20
|
+
params = {
|
|
21
|
+
client_id: Shakha.config.github_client_id,
|
|
22
|
+
redirect_uri: redirect_uri,
|
|
23
|
+
scope: scopes.join(" "),
|
|
24
|
+
state: state
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
"#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def exchange_code(code:, code_verifier:, redirect_uri:)
|
|
31
|
+
response = http_post(TOKEN_URL, {
|
|
32
|
+
code: code,
|
|
33
|
+
client_id: Shakha.config.github_client_id,
|
|
34
|
+
client_secret: Shakha.config.github_client_secret,
|
|
35
|
+
redirect_uri: redirect_uri
|
|
36
|
+
}, accept: :json)
|
|
37
|
+
|
|
38
|
+
JSON.parse(response.body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
42
|
+
access_token = token_response["access_token"]
|
|
43
|
+
raise OAuthError, "No access_token received" unless access_token
|
|
44
|
+
|
|
45
|
+
user_data = fetch_user(access_token)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
provider: :github,
|
|
49
|
+
uid: user_data["id"].to_s,
|
|
50
|
+
email: user_data["email"],
|
|
51
|
+
name: user_data["name"] || user_data["login"],
|
|
52
|
+
picture: user_data["avatar_url"]
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scopes
|
|
57
|
+
%w[user:email]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def fetch_user(access_token)
|
|
63
|
+
uri = URI.parse(USER_API_URL)
|
|
64
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
65
|
+
http.use_ssl = true
|
|
66
|
+
http.open_timeout = 5
|
|
67
|
+
http.read_timeout = 10
|
|
68
|
+
|
|
69
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
70
|
+
request["Authorization"] = "Bearer #{access_token}"
|
|
71
|
+
request["Accept"] = "application/json"
|
|
72
|
+
|
|
73
|
+
response = http.request(request)
|
|
74
|
+
JSON.parse(response.body)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def http_post(url, body, accept: :json)
|
|
78
|
+
uri = URI.parse(url)
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = true
|
|
81
|
+
http.open_timeout = 5
|
|
82
|
+
http.read_timeout = 10
|
|
83
|
+
|
|
84
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
85
|
+
request["Accept"] = "application/json" if accept == :json
|
|
86
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
87
|
+
request.body = URI.encode_www_form(body)
|
|
88
|
+
|
|
89
|
+
response = http.request(request)
|
|
90
|
+
|
|
91
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
92
|
+
raise OAuthError, "GitHub returned HTTP #{response.code}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
response
|
|
96
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
97
|
+
raise OAuthError, "Unable to reach GitHub: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Shakha
|
|
7
|
+
module Providers
|
|
8
|
+
class Google < Base
|
|
9
|
+
AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
10
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
11
|
+
|
|
12
|
+
def provider_name
|
|
13
|
+
:google
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authorize_url(state:, code_challenge:, redirect_uri:, nonce: nil)
|
|
17
|
+
params = {
|
|
18
|
+
client_id: Shakha.config.google_client_id,
|
|
19
|
+
redirect_uri: redirect_uri,
|
|
20
|
+
response_type: "code",
|
|
21
|
+
scope: scopes.join(" "),
|
|
22
|
+
code_challenge: code_challenge,
|
|
23
|
+
code_challenge_method: "S256",
|
|
24
|
+
state: state,
|
|
25
|
+
access_type: "offline",
|
|
26
|
+
prompt: "consent",
|
|
27
|
+
nonce: nonce
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
"#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exchange_code(code:, code_verifier:, redirect_uri:)
|
|
34
|
+
response = http_post(TOKEN_URL, {
|
|
35
|
+
code: code,
|
|
36
|
+
client_id: Shakha.config.google_client_id,
|
|
37
|
+
client_secret: Shakha.config.google_client_secret,
|
|
38
|
+
redirect_uri: redirect_uri,
|
|
39
|
+
grant_type: "authorization_code",
|
|
40
|
+
code_verifier: code_verifier
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
JSON.parse(response.body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def identity_from_response(token_response, expected_nonce: nil)
|
|
47
|
+
id_token = token_response["id_token"]
|
|
48
|
+
raise OAuthError, "No id_token received" unless id_token
|
|
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.
|
|
53
|
+
payload = JWT.decode(id_token, nil, false)[0]
|
|
54
|
+
verify_claims!(payload, expected_nonce)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
provider: :google,
|
|
58
|
+
uid: payload["sub"],
|
|
59
|
+
email: payload["email"],
|
|
60
|
+
name: payload["name"],
|
|
61
|
+
picture: payload["picture"]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scopes
|
|
66
|
+
%w[openid email profile]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
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
|
+
|
|
90
|
+
def http_post(url, body)
|
|
91
|
+
uri = URI.parse(url)
|
|
92
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
93
|
+
http.use_ssl = true
|
|
94
|
+
http.open_timeout = 5
|
|
95
|
+
http.read_timeout = 10
|
|
96
|
+
|
|
97
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
98
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
99
|
+
request.body = URI.encode_www_form(body)
|
|
100
|
+
|
|
101
|
+
response = http.request(request)
|
|
102
|
+
|
|
103
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
104
|
+
raise OAuthError, "Google returned HTTP #{response.code}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
response
|
|
108
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
109
|
+
raise OAuthError, "Unable to reach Google: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shakha/providers/base"
|
|
4
|
+
require "shakha/providers/google"
|
|
5
|
+
require "shakha/providers/github"
|
|
6
|
+
|
|
7
|
+
module Shakha
|
|
8
|
+
module Providers
|
|
9
|
+
PROVIDER_MAP = {
|
|
10
|
+
google: "Shakha::Providers::Google",
|
|
11
|
+
github: "Shakha::Providers::GitHub"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.resolve(name)
|
|
15
|
+
class_name = PROVIDER_MAP[name.to_sym] || raise(ConfigurationError, "Unknown provider: #{name}")
|
|
16
|
+
class_name.constantize.new
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|