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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ VERSION = "0.1.0"
5
+ end