shakha 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/app/assets/stylesheets/shakha.css +193 -0
- data/app/controllers/shakha/application_controller.rb +20 -0
- data/app/controllers/shakha/auth_controller.rb +189 -0
- data/app/controllers/shakha/jwks_controller.rb +10 -0
- data/app/controllers/shakha/openid_controller.rb +21 -0
- data/app/controllers/shakha/session_controller.rb +33 -0
- data/app/models/shakha/client.rb +20 -0
- data/app/models/shakha/session.rb +33 -0
- data/app/models/shakha/user.rb +16 -0
- data/app/views/shakha/auth/callback.html.erb +12 -0
- data/app/views/shakha/auth/new.html.erb +29 -0
- data/app/views/shakha/errors/show.html.erb +18 -0
- data/app/views/shakha/layouts/shakha.html.erb +12 -0
- data/generators/shakha/install_generator.rb +37 -0
- data/generators/shakha/templates/initializer.rb.erb +29 -0
- data/generators/shakha/templates/migration.rb.erb +45 -0
- data/lib/shakha/config.rb +39 -0
- data/lib/shakha/controller_helpers.rb +70 -0
- data/lib/shakha/engine.rb +93 -0
- data/lib/shakha/error_handler.rb +34 -0
- data/lib/shakha/jwt_handler.rb +127 -0
- data/lib/shakha/middleware.rb +49 -0
- data/lib/shakha/pairwise.rb +26 -0
- data/lib/shakha/pkce.rb +63 -0
- data/lib/shakha/version.rb +5 -0
- data/lib/shakha.rb +44 -0
- metadata +99 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% content_for :title, "Authentication Error" %>
|
|
2
|
+
|
|
3
|
+
<div class="shakha-container">
|
|
4
|
+
<div class="shakha-card shakha-card-error">
|
|
5
|
+
<div class="shakha-error-icon">
|
|
6
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7
|
+
<circle cx="12" cy="12" r="10"/>
|
|
8
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
9
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
10
|
+
</svg>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<h1>Authentication Failed</h1>
|
|
14
|
+
<p class="shakha-error-message"><%= @message %></p>
|
|
15
|
+
|
|
16
|
+
<%= link_to "Try Again", shakha.new_auth_path, class: "shakha-button shakha-button-primary" %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= yield :title %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= stylesheet_link_tag "shakha", "data-turbo-track": "reload" %>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= yield %>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This generator creates a migration for the Shakha tables
|
|
3
|
+
|
|
4
|
+
require "rails/generators/active_record/migration"
|
|
5
|
+
require "rails/generators/active_record/migration/migration_generator"
|
|
6
|
+
|
|
7
|
+
module Shakha
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
class_option :skip_migration, type: :boolean, default: false, desc: "Skip migration generation"
|
|
12
|
+
|
|
13
|
+
def copy_initializer
|
|
14
|
+
template "initializer.rb.erb", "config/initializers/shakha.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration
|
|
18
|
+
return if options[:skip_migration]
|
|
19
|
+
|
|
20
|
+
migration_template(
|
|
21
|
+
"migration.rb.erb",
|
|
22
|
+
"db/migrate/create_shakha_tables.rb",
|
|
23
|
+
migration_version: migration_version
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_routes
|
|
28
|
+
route 'mount Shakha::Engine => "/auth/shakha", as: :shakha'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def migration_version
|
|
34
|
+
">= 7.1" ? "[7.1]" : ""
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shakha Auth Configuration
|
|
4
|
+
#
|
|
5
|
+
# Set these environment variables:
|
|
6
|
+
# SHAKHA_SERVICE_SECRET - Secret key for pairwise subject derivation (HMAC-SHA256)
|
|
7
|
+
# GOOGLE_CLIENT_ID - Google OAuth client ID
|
|
8
|
+
# GOOGLE_CLIENT_SECRET - Google OAuth client secret
|
|
9
|
+
# SHAKHA_APP_ORIGIN - Your application's origin (e.g., http://localhost:3000)
|
|
10
|
+
# SHAKHA_SERVICE_URL - Optional: URL of standalone Shakha service (omit for embedded mode)
|
|
11
|
+
#
|
|
12
|
+
# For ES256 JWT signing, set either:
|
|
13
|
+
# SHAKHA_SIGNING_KEY - EC P-256 private key in PEM or base64 format
|
|
14
|
+
# SHAKHA_KEY_ID - Key ID for JWKS (optional, defaults to "default")
|
|
15
|
+
|
|
16
|
+
Shakha.setup do |config|
|
|
17
|
+
config.app_origin = ENV.fetch("SHAKHA_APP_ORIGIN", "http://localhost:3000")
|
|
18
|
+
config.service_url = ENV["SHAKHA_SERVICE_URL"]
|
|
19
|
+
config.service_secret = ENV["SHAKHA_SERVICE_SECRET"]
|
|
20
|
+
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
21
|
+
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
22
|
+
config.issuer = ENV.fetch("SHAKHA_ISSUER", "https://shakha.dev")
|
|
23
|
+
config.session_lifetime = ENV.fetch("SHAKHA_SESSION_LIFETIME", "30.days")
|
|
24
|
+
|
|
25
|
+
# JWT signing keys (required for service mode)
|
|
26
|
+
config.signing_key = ENV["SHAKHA_SIGNING_KEY"]
|
|
27
|
+
config.verification_key = ENV["SHAKHA_VERIFICATION_KEY"]
|
|
28
|
+
config.key_id = ENV["SHAKHA_KEY_ID"] || "default"
|
|
29
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :shakha_clients, id: :uuid do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :origin, null: false
|
|
8
|
+
t.string :client_id
|
|
9
|
+
t.json :metadata, default: {}
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :shakha_clients, :origin, unique: true
|
|
15
|
+
add_index :shakha_clients, :client_id, unique: true
|
|
16
|
+
|
|
17
|
+
create_table :shakha_users, id: :uuid do |t|
|
|
18
|
+
t.string :pairwise_sub, null: false
|
|
19
|
+
t.string :email
|
|
20
|
+
t.string :name
|
|
21
|
+
t.string :picture
|
|
22
|
+
t.references :client, type: :uuid, null: false
|
|
23
|
+
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :shakha_users, :pairwise_sub, unique: true
|
|
28
|
+
add_index :shakha_users, :email
|
|
29
|
+
|
|
30
|
+
create_table :shakha_sessions, id: :uuid do |t|
|
|
31
|
+
t.string :token, null: false
|
|
32
|
+
t.string :jti, null: false
|
|
33
|
+
t.references :user, type: :uuid, null: false
|
|
34
|
+
t.references :client, type: :uuid, null: false
|
|
35
|
+
t.string :ip_address
|
|
36
|
+
t.string :user_agent
|
|
37
|
+
|
|
38
|
+
t.timestamps
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
add_index :shakha_sessions, :token, unique: true
|
|
42
|
+
add_index :shakha_sessions, :jti, unique: true
|
|
43
|
+
add_index :shakha_sessions, :created_at
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :app_origin,
|
|
6
|
+
:service_url,
|
|
7
|
+
:service_secret,
|
|
8
|
+
:google_client_id,
|
|
9
|
+
:google_client_secret,
|
|
10
|
+
:issuer,
|
|
11
|
+
:session_lifetime
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@session_lifetime = 30.days
|
|
15
|
+
@issuer = "https://shakha.dev"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def embedded?
|
|
19
|
+
service_url.blank?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def service_base_url
|
|
23
|
+
return app_origin if embedded?
|
|
24
|
+
|
|
25
|
+
service_url.chomp("/")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def client_id
|
|
29
|
+
return @client_id if defined?(@client_id)
|
|
30
|
+
|
|
31
|
+
origin = URI.parse(app_origin).origin
|
|
32
|
+
@client_id = "origin:#{origin}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def audience
|
|
36
|
+
client_id
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
module ControllerHelpers
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
helper_method :current_session, :current_user, :signed_in?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def current_session
|
|
16
|
+
return @current_session if defined?(@current_session)
|
|
17
|
+
|
|
18
|
+
@current_session = find_session || authenticate_from_bearer || authenticate_from_cookie
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def current_user
|
|
22
|
+
current_session&.user
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def signed_in?
|
|
26
|
+
current_session.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def authenticate!
|
|
30
|
+
return if signed_in?
|
|
31
|
+
|
|
32
|
+
redirect_to shakha.new_auth_path(return_to: request.fullpath)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def authenticate_from_bearer
|
|
36
|
+
return unless (token = bearer_token)
|
|
37
|
+
|
|
38
|
+
payload = Shakha.verify_token(token)
|
|
39
|
+
find_session_by_jti(payload["jti"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def authenticate_from_cookie
|
|
43
|
+
find_session_by_token(session_token)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def bearer_token
|
|
47
|
+
pattern = /^Bearer /
|
|
48
|
+
header = request.headers["Authorization"]
|
|
49
|
+
return unless header&.match?(pattern)
|
|
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)
|
|
60
|
+
|
|
61
|
+
Shakha::Session.active.find_by(token: token)
|
|
62
|
+
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
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Shakha
|
|
8
|
+
|
|
9
|
+
config.app_middleware.use Shakha::Middleware
|
|
10
|
+
|
|
11
|
+
config.after_initialize do
|
|
12
|
+
if Shakha.config.app_origin.blank?
|
|
13
|
+
raise ConfigurationError, "Shakha.app_origin must be set"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class EngineRouter
|
|
18
|
+
def self.draw
|
|
19
|
+
Drawer.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Drawer
|
|
23
|
+
def initialize
|
|
24
|
+
@routes = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resources(*args, &block)
|
|
28
|
+
resource_options = args.last.is_a?(Hash) ? args.pop : {}
|
|
29
|
+
resource_name = args.first
|
|
30
|
+
|
|
31
|
+
@routes << { type: :resources, name: resource_name, options: resource_options, block: block }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resource(*args, &block)
|
|
35
|
+
resource_options = args.last.is_a?(Hash) ? args.pop : {}
|
|
36
|
+
resource_name = args.first
|
|
37
|
+
|
|
38
|
+
@routes << { type: :resource, name: resource_name, options: resource_options, block: block }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get(path, to:, as: nil)
|
|
42
|
+
@routes << { type: :get, path: path, to: to, as: as }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def post(path, to:, as: nil)
|
|
46
|
+
@routes << { type: :post, path: path, to: to, as: as }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def match(path, to:, via:, as: nil)
|
|
50
|
+
@routes << { type: :match, path: path, to: to, via: via, as: as }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def routes
|
|
54
|
+
@routes
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
initializer "shakha.routes" do |app|
|
|
60
|
+
Shakha::EngineRouter.draw do
|
|
61
|
+
get "/auth/shakha", to: "auth#new", as: :new_auth
|
|
62
|
+
get "/auth/shakha/authorize", to: "auth#authorize", as: :authorize
|
|
63
|
+
get "/auth/shakha/callback", to: "auth#callback", as: :callback
|
|
64
|
+
post "/auth/shakha/token", to: "auth#token", as: :token
|
|
65
|
+
get "/auth/shakha/error", to: "auth#error", as: :error
|
|
66
|
+
|
|
67
|
+
get "/auth/shakha/session", to: "session#show", as: :session
|
|
68
|
+
post "/auth/shakha/session/check", to: "session#check", as: :check_session
|
|
69
|
+
delete "/auth/shakha/session", to: "session#destroy", as: :destroy_session
|
|
70
|
+
|
|
71
|
+
get "/.well-known/jwks.json", to: "jwks#show"
|
|
72
|
+
get "/.well-known/openid-configuration", to: "openid#configuration"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Shakha::EngineRouter.routes.each do |route|
|
|
76
|
+
case route[:type]
|
|
77
|
+
when :get
|
|
78
|
+
app.routes.append do
|
|
79
|
+
get route[:path], to: route[:to], as: route[:as]
|
|
80
|
+
end
|
|
81
|
+
when :post
|
|
82
|
+
app.routes.append do
|
|
83
|
+
post route[:path], to: route[:to], as: route[:as]
|
|
84
|
+
end
|
|
85
|
+
when :match
|
|
86
|
+
app.routes.append do
|
|
87
|
+
match route[:path], to: route[:to], as: route[:as], via: route[:via]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
module ErrorHandler
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
11
|
+
rescue_from Shakha::JWTError, with: :unauthorized
|
|
12
|
+
rescue_from Shakha::PKCEError, with: :bad_request
|
|
13
|
+
rescue_from Shakha::GoogleOAuthError, with: :bad_gateway
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def not_found(exception)
|
|
19
|
+
render json: { error: exception.message }, status: :not_found
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unauthorized(exception)
|
|
23
|
+
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def bad_request(exception)
|
|
27
|
+
render json: { error: exception.message }, status: :bad_request
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def bad_gateway(exception)
|
|
31
|
+
render json: { error: exception.message }, status: :bad_gateway
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
class ConfigurationError < StandardError; end
|
|
7
|
+
class JWTError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class JwtHandler
|
|
10
|
+
ALGORITHM = "ES256"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def encode(payload, exp: 24.hours.from_now)
|
|
14
|
+
secret = signing_key || raise(ConfigurationError, "RSA/EC private key required for signing")
|
|
15
|
+
|
|
16
|
+
header = {
|
|
17
|
+
alg: ALGORITHM,
|
|
18
|
+
typ: "JWT",
|
|
19
|
+
kid: key_id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
payload = payload.with_indifferent_access.merge(
|
|
23
|
+
iss: Shakha.config.issuer,
|
|
24
|
+
aud: Shakha.config.audience,
|
|
25
|
+
iat: Time.current.to_i,
|
|
26
|
+
exp: exp.to_i,
|
|
27
|
+
jti: SecureRandom.uuid
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
JWT.encode(payload, secret, ALGORITHM, header)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def verify(token, audience: nil)
|
|
34
|
+
public_key = verification_key || raise(ConfigurationError, "RSA/EC public key required for verification")
|
|
35
|
+
|
|
36
|
+
decoded = JWT.decode(
|
|
37
|
+
token,
|
|
38
|
+
public_key,
|
|
39
|
+
true,
|
|
40
|
+
{
|
|
41
|
+
algorithm: ALGORITHM,
|
|
42
|
+
iss: Shakha.config.issuer,
|
|
43
|
+
aud: audience || Shakha.config.audience,
|
|
44
|
+
verify_iss: true,
|
|
45
|
+
verify_aud: true,
|
|
46
|
+
verify_expiration: true
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
decoded[0].with_indifferent_access
|
|
51
|
+
rescue JWT::DecodeError => e
|
|
52
|
+
raise JWTError, e.message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def jwks
|
|
56
|
+
{
|
|
57
|
+
keys: [
|
|
58
|
+
{
|
|
59
|
+
kty: "EC",
|
|
60
|
+
crv: "P-256",
|
|
61
|
+
x: Base64.urlsafe_encode64(public_key_point&.x || public_key_raw_point[0..31], padding: false),
|
|
62
|
+
y: Base64.urlsafe_encode64(public_key_point&.y || public_key_raw_point[32..63], padding: false),
|
|
63
|
+
use: "sig",
|
|
64
|
+
alg: ALGORITHM,
|
|
65
|
+
kid: key_id
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}.to_json
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def signing_key
|
|
74
|
+
return @signing_key if defined?(@signing_key)
|
|
75
|
+
|
|
76
|
+
key_material = Shakha.config.signing_key
|
|
77
|
+
return @signing_key = nil unless key_material
|
|
78
|
+
|
|
79
|
+
if key_material.is_a?(OpenSSL::PKey::EC)
|
|
80
|
+
@signing_key = key_material
|
|
81
|
+
elsif key_material.start_with?("-----BEGIN")
|
|
82
|
+
@signing_key = OpenSSL::PKey::EC.new(key_material)
|
|
83
|
+
else
|
|
84
|
+
@signing_key = OpenSSL::PKey::EC.new(Base64.decode64(key_material))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def verification_key
|
|
89
|
+
return signing_key&.public_key if signing_key
|
|
90
|
+
|
|
91
|
+
public_material = Shakha.config.verification_key
|
|
92
|
+
return nil unless public_material
|
|
93
|
+
|
|
94
|
+
if public_material.start_with?("-----BEGIN")
|
|
95
|
+
OpenSSL::PKey::EC.new(public_material)
|
|
96
|
+
else
|
|
97
|
+
OpenSSL::PKey::EC.new(Base64.decode64(public_material))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def public_key_point
|
|
102
|
+
@public_key_point ||= begin
|
|
103
|
+
key = verification_key || signing_key&.public_key
|
|
104
|
+
return nil unless key
|
|
105
|
+
|
|
106
|
+
group = key.group
|
|
107
|
+
point = key.public_key
|
|
108
|
+
{ x: point.x, y: point.y }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def public_key_raw_point
|
|
113
|
+
@public_key_raw_point ||= begin
|
|
114
|
+
key = verification_key || signing_key&.public_key
|
|
115
|
+
return nil unless key
|
|
116
|
+
|
|
117
|
+
point = key.public_key
|
|
118
|
+
[point.x, point.y].map { |n| n.to_s(16).rjust(64, "0") }.join.scan(/../).map { |b| b.to_i(16).chr }.join
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def key_id
|
|
123
|
+
Shakha.config.key_id || "default"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shakha
|
|
4
|
+
class Middleware
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
@env = env
|
|
11
|
+
@request = ActionDispatch::Request.new(env)
|
|
12
|
+
|
|
13
|
+
if verify_token_request?
|
|
14
|
+
verify_token!
|
|
15
|
+
else
|
|
16
|
+
@app.call(env)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :request
|
|
23
|
+
|
|
24
|
+
def verify_token_request?
|
|
25
|
+
request.path == "/auth/shakha/token" && request.post?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def verify_token!
|
|
29
|
+
token = extract_token || raise(JWTError, "Missing token")
|
|
30
|
+
payload = Shakha.verify_token(token)
|
|
31
|
+
|
|
32
|
+
@env["shakha.user_id"] = payload[:pairwise_sub]
|
|
33
|
+
@env["shakha.email"] = payload[:email]
|
|
34
|
+
@env["shakha.name"] = payload[:name]
|
|
35
|
+
|
|
36
|
+
@app.call(@env)
|
|
37
|
+
rescue JWTError => e
|
|
38
|
+
[401, { "Content-Type" => "application/json" }, [{ error: e.message }.to_json]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_token
|
|
42
|
+
if request.content_type == "application/json"
|
|
43
|
+
JSON.parse(request.body.read)["id_token"]
|
|
44
|
+
elsif request.params["id_token"].present?
|
|
45
|
+
request.params["id_token"]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "jwt"
|
|
6
|
+
|
|
7
|
+
module Shakha
|
|
8
|
+
module Pairwise
|
|
9
|
+
HMAC_DIGEST = OpenSSL::Digest::SHA256.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def derive(google_sub, client_id)
|
|
13
|
+
secret = secret_key
|
|
14
|
+
input = "#{google_sub}:#{client_id}"
|
|
15
|
+
digest = OpenSSL::HMAC.hexdigest(HMAC_DIGEST, secret, input)
|
|
16
|
+
"ps_#{digest}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def secret_key
|
|
22
|
+
Shakha.config.service_secret || raise(Shakha::ConfigurationError, "SHAKHA_SECRET not set")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/shakha/pkce.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Shakha
|
|
6
|
+
module PKCEMixin
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
CODE_VERIFIER_LENGTH = 64
|
|
10
|
+
CODE_CHALLENGE_METHOD = "S256"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def generate_code_verifier
|
|
14
|
+
SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH)
|
|
15
|
+
.tr("-_", "+/")
|
|
16
|
+
.slice(0, CODE_VERIFIER_LENGTH)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate_code_challenge(verifier)
|
|
20
|
+
Base64.urlsafe_encode64(
|
|
21
|
+
OpenSSL::Digest::SHA256.digest(verifier),
|
|
22
|
+
padding: false
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def create_pkce_bundle
|
|
30
|
+
verifier = PKCEMixin.generate_code_verifier
|
|
31
|
+
challenge = PKCEMixin.generate_code_challenge(verifier)
|
|
32
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
33
|
+
|
|
34
|
+
session[:shakha_pkce] = {
|
|
35
|
+
verifier: verifier,
|
|
36
|
+
state: state,
|
|
37
|
+
return_to: params[:return_to] || request.referer || "/"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
{ challenge: challenge, state: state }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def verify_pkce!(code_verifier)
|
|
44
|
+
stored = session[:shakha_pkce]
|
|
45
|
+
|
|
46
|
+
raise PKCEError, "No PKCE session found" unless stored
|
|
47
|
+
raise PKCEError, "State mismatch" unless stored[:state] == params[:state]
|
|
48
|
+
|
|
49
|
+
computed = PKCEMixin.generate_code_challenge(code_verifier)
|
|
50
|
+
raise PKCEError, "Invalid code verifier" unless computed == params[:code_challenge]
|
|
51
|
+
|
|
52
|
+
session.delete(:shakha_pkce)
|
|
53
|
+
stored[:verifier]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pkce_state
|
|
57
|
+
session[:shakha_pkce]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class PKCEError < StandardError; end
|
|
62
|
+
class GoogleOAuthError < StandardError; end
|
|
63
|
+
end
|