shakha 0.2.0 → 0.3.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.
@@ -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_user, :current_session, :signed_in?
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,24 @@ module Shakha
29
28
  def authenticate!
30
29
  return if signed_in?
31
30
 
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"])
31
+ respond_to do |format|
32
+ format.html { redirect_to shakha.new_auth_path(return_to: request.fullpath) }
33
+ format.json { render json: { error: "Authentication required" }, status: :unauthorized }
34
+ end
40
35
  end
41
36
 
42
- def authenticate_from_cookie
43
- find_session_by_token(session_token)
37
+ def find_session_from_cookie
38
+ token = cookies.encrypted[:shakha_session_token]
39
+ return unless token
40
+ Shakha::Session.active.find_by(token: token)
44
41
  end
45
42
 
46
- def bearer_token
47
- pattern = /^Bearer /
43
+ def find_session_from_bearer
48
44
  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)
45
+ return unless header&.start_with?("Bearer ")
60
46
 
47
+ token = header.delete_prefix("Bearer ")
61
48
  Shakha::Session.active.find_by(token: token)
62
49
  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
50
  end
70
- end
51
+ end
data/lib/shakha/engine.rb CHANGED
@@ -4,30 +4,20 @@ 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
- get "authorize" => "auth#authorize"
18
- get "callback" => "auth#callback"
19
- post "token" => "auth#token"
20
- get "error" => "auth#error"
21
-
22
- get "session" => "session#show"
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
+ get ":provider/authorize" => "auth#authorize"
15
+ get ":provider/callback" => "auth#callback"
16
+ delete "sign_out" => "auth#destroy"
17
+ get "error" => "auth#error"
28
18
 
29
- get ".well-known/jwks.json" => "jwks#show"
30
- get ".well-known/openid-configuration" => "openid#configuration"
19
+ get "session" => "session#show"
20
+ get "session/check" => "session#check"
31
21
  end
32
22
  end
33
23
  end
@@ -8,9 +8,8 @@ module Shakha
8
8
 
9
9
  included do
10
10
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
11
- rescue_from Shakha::JWTError, with: :unauthorized
12
11
  rescue_from Shakha::PKCEError, with: :bad_request
13
- rescue_from Shakha::GoogleOAuthError, with: :bad_gateway
12
+ rescue_from Shakha::OAuthError, with: :bad_gateway
14
13
  end
15
14
 
16
15
  private
@@ -28,7 +27,7 @@ module Shakha
28
27
  end
29
28
 
30
29
  def bad_gateway(exception)
31
- Rails.logger.error("[Shakha] Google OAuth error: #{exception.message}")
30
+ Rails.logger.error("[Shakha] OAuth error: #{exception.message}")
32
31
  render json: { error: "Authentication service unavailable" }, status: :bad_gateway
33
32
  end
34
33
  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:)
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)
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,99 @@
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
+ def authorize_url(state:, code_challenge:, redirect_uri:)
18
+ params = {
19
+ client_id: Shakha.config.github_client_id,
20
+ redirect_uri: redirect_uri,
21
+ scope: scopes.join(" "),
22
+ state: state
23
+ }
24
+
25
+ "#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
26
+ end
27
+
28
+ def exchange_code(code:, code_verifier:, redirect_uri:)
29
+ response = http_post(TOKEN_URL, {
30
+ code: code,
31
+ client_id: Shakha.config.github_client_id,
32
+ client_secret: Shakha.config.github_client_secret,
33
+ redirect_uri: redirect_uri
34
+ }, accept: :json)
35
+
36
+ JSON.parse(response.body)
37
+ end
38
+
39
+ def identity_from_response(token_response)
40
+ access_token = token_response["access_token"]
41
+ raise OAuthError, "No access_token received" unless access_token
42
+
43
+ user_data = fetch_user(access_token)
44
+
45
+ {
46
+ provider: :github,
47
+ uid: user_data["id"].to_s,
48
+ email: user_data["email"],
49
+ name: user_data["name"] || user_data["login"],
50
+ picture: user_data["avatar_url"]
51
+ }
52
+ end
53
+
54
+ def scopes
55
+ %w[user:email]
56
+ end
57
+
58
+ private
59
+
60
+ def fetch_user(access_token)
61
+ uri = URI.parse(USER_API_URL)
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = true
64
+ http.open_timeout = 5
65
+ http.read_timeout = 10
66
+
67
+ request = Net::HTTP::Get.new(uri.request_uri)
68
+ request["Authorization"] = "Bearer #{access_token}"
69
+ request["Accept"] = "application/json"
70
+
71
+ response = http.request(request)
72
+ JSON.parse(response.body)
73
+ end
74
+
75
+ def http_post(url, body, accept: :json)
76
+ uri = URI.parse(url)
77
+ http = Net::HTTP.new(uri.host, uri.port)
78
+ http.use_ssl = true
79
+ http.open_timeout = 5
80
+ http.read_timeout = 10
81
+
82
+ request = Net::HTTP::Post.new(uri.request_uri)
83
+ request["Accept"] = "application/json" if accept == :json
84
+ request["Content-Type"] = "application/x-www-form-urlencoded"
85
+ request.body = URI.encode_www_form(body)
86
+
87
+ response = http.request(request)
88
+
89
+ unless response.is_a?(Net::HTTPSuccess)
90
+ raise OAuthError, "GitHub returned HTTP #{response.code}"
91
+ end
92
+
93
+ response
94
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
95
+ raise OAuthError, "Unable to reach GitHub: #{e.message}"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,90 @@
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:)
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: SecureRandom.urlsafe_base64(32)
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)
47
+ id_token = token_response["id_token"]
48
+ raise OAuthError, "No id_token received" unless id_token
49
+
50
+ payload = JWT.decode(id_token, nil, false)[0]
51
+
52
+ {
53
+ provider: :google,
54
+ uid: payload["sub"],
55
+ email: payload["email"],
56
+ name: payload["name"],
57
+ picture: payload["picture"]
58
+ }
59
+ end
60
+
61
+ def scopes
62
+ %w[openid email profile]
63
+ end
64
+
65
+ private
66
+
67
+ def http_post(url, body)
68
+ uri = URI.parse(url)
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+ http.use_ssl = true
71
+ http.open_timeout = 5
72
+ http.read_timeout = 10
73
+
74
+ request = Net::HTTP::Post.new(uri.request_uri)
75
+ request["Content-Type"] = "application/x-www-form-urlencoded"
76
+ request.body = URI.encode_www_form(body)
77
+
78
+ response = http.request(request)
79
+
80
+ unless response.is_a?(Net::HTTPSuccess)
81
+ raise OAuthError, "Google returned HTTP #{response.code}"
82
+ end
83
+
84
+ response
85
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
86
+ raise OAuthError, "Unable to reach Google: #{e.message}"
87
+ end
88
+ end
89
+ end
90
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/shakha.rb CHANGED
@@ -3,14 +3,11 @@
3
3
  require "shakha/version"
4
4
  require "shakha/config"
5
5
  require "shakha/config_validator"
6
- require "shakha/pairwise"
7
- require "shakha/jwt_handler"
8
6
  require "shakha/pkce"
9
7
  require "shakha/rate_limiter"
10
- require "shakha/auditable"
11
8
  require "shakha/error_handler"
12
9
  require "shakha/controller_helpers"
13
- require "shakha/middleware"
10
+ require "shakha/providers"
14
11
  require "shakha/engine"
15
12
 
16
13
  module Shakha
@@ -22,32 +19,9 @@ module Shakha
22
19
  def config
23
20
  @config ||= Config.new
24
21
  end
25
-
26
- def verify_token(id_token, audience: nil)
27
- JwtHandler.verify(id_token, audience: audience || default_audience)
28
- end
29
-
30
- def sign_token(payload, exp: 24.hours.from_now)
31
- JwtHandler.encode(payload, exp: exp)
32
- end
33
-
34
- def derive_pairwise_sub(google_sub, client_id = nil)
35
- Pairwise.derive(google_sub, client_id || default_client_id)
36
- end
37
-
38
- private
39
-
40
- def default_audience
41
- "origin:#{config.app_origin&.then { |url| URI.parse(url).origin }}"
42
- end
43
-
44
- def default_client_id
45
- "origin:#{URI.parse(config.app_origin).origin}"
46
- end
47
22
  end
48
23
 
49
24
  class ConfigurationError < StandardError; end
50
- class JWTError < StandardError; end
51
25
  class PKCEError < StandardError; end
52
- class GoogleOAuthError < StandardError; end
26
+ class OAuthError < StandardError; end
53
27
  end
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
@@ -81,8 +81,6 @@ files:
81
81
  - app/assets/stylesheets/shakha.css
82
82
  - app/controllers/shakha/application_controller.rb
83
83
  - app/controllers/shakha/auth_controller.rb
84
- - app/controllers/shakha/jwks_controller.rb
85
- - app/controllers/shakha/openid_controller.rb
86
84
  - app/controllers/shakha/session_controller.rb
87
85
  - app/models/shakha/client.rb
88
86
  - app/models/shakha/session.rb
@@ -90,23 +88,22 @@ files:
90
88
  - app/views/shakha/auth/callback.html.erb
91
89
  - app/views/shakha/auth/error.html.erb
92
90
  - app/views/shakha/auth/new.html.erb
93
- - app/views/shakha/auth/sessions.html.erb
94
91
  - app/views/shakha/errors/show.html.erb
95
92
  - app/views/shakha/layouts/shakha.html.erb
96
93
  - lib/generators/shakha/install_generator.rb
97
94
  - lib/generators/shakha/templates/initializer.rb.erb
98
95
  - lib/generators/shakha/templates/migration.rb.erb
99
96
  - lib/shakha.rb
100
- - lib/shakha/auditable.rb
101
97
  - lib/shakha/config.rb
102
98
  - lib/shakha/config_validator.rb
103
99
  - lib/shakha/controller_helpers.rb
104
100
  - lib/shakha/engine.rb
105
101
  - lib/shakha/error_handler.rb
106
- - lib/shakha/jwt_handler.rb
107
- - lib/shakha/middleware.rb
108
- - lib/shakha/pairwise.rb
109
102
  - lib/shakha/pkce.rb
103
+ - lib/shakha/providers.rb
104
+ - lib/shakha/providers/base.rb
105
+ - lib/shakha/providers/github.rb
106
+ - lib/shakha/providers/google.rb
110
107
  - lib/shakha/rate_limiter.rb
111
108
  - lib/shakha/version.rb
112
109
  homepage: https://shakha.dev
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Shakha
4
- class JwksController < ApplicationController
5
- def show
6
- render json: Shakha::JwtHandler.jwks,
7
- content_type: "application/json"
8
- end
9
- end
10
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Shakha
4
- class OpenidController < ApplicationController
5
- def configuration
6
- render json: {
7
- issuer: Shakha.config.issuer,
8
- authorization_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/authorize",
9
- token_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/token",
10
- userinfo_endpoint: "#{Shakha.config.service_base_url}/auth/shakha/session",
11
- jwks_uri: "#{Shakha.config.service_base_url}/.well-known/jwks.json",
12
- response_types_supported: ["code"],
13
- grant_types_supported: ["authorization_code"],
14
- code_challenge_methods_supported: ["S256"],
15
- subject_types_supported: ["pairwise"],
16
- id_token_signing_alg_values_supported: ["ES256"],
17
- scopes_supported: ["openid", "email", "profile"]
18
- }, content_type: "application/json"
19
- end
20
- end
21
- end
@@ -1,66 +0,0 @@
1
- <% content_for :title, "Active Sessions" %>
2
-
3
- <div class="sh-layout sh-animate-fade">
4
- <div class="sh-card sh-animate-slide" style="max-width: 560px;">
5
- <div class="sh-card__header">
6
- <div class="sh-brand">
7
- <div class="sh-brand__icon">S</div>
8
- <div>
9
- <div class="sh-brand__name">Active Sessions</div>
10
- <div class="sh-brand__subtitle">Manage your signed-in devices</div>
11
- </div>
12
- </div>
13
- </div>
14
-
15
- <div class="sh-sessions">
16
- <div class="sh-sessions__header">
17
- <span class="sh-sessions__title">Devices</span>
18
- <span class="sh-sessions__count"><%= @sessions&.size || 0 %> active</span>
19
- </div>
20
-
21
- <% if @sessions&.any? %>
22
- <% @sessions.each do |session| %>
23
- <div class="sh-session <%= 'sh-session--current' if session.token == @current_token %>">
24
- <div class="sh-session__info">
25
- <div style="display: flex; align-items: center; gap: var(--sh-space-2);">
26
- <% if session.token == @current_token %>
27
- <span class="sh-session__badge">Current</span>
28
- <% end %>
29
- <span class="sh-session__device">Web Browser</span>
30
- </div>
31
- <div class="sh-session__meta">
32
- <%= session.ip_address || "Unknown IP" %> ·
33
- <%= session.created_at.strftime("%b %d, %Y at %I:%M %p") %>
34
- </div>
35
- </div>
36
-
37
- <% if session.token != @current_token %>
38
- <%= button_to revoke_session_path(session.id),
39
- method: :delete,
40
- class: "sh-session__btn",
41
- data: { turbo: false, confirm: "Revoke this session?" } do %>
42
- Revoke
43
- <% end %>
44
- <% end %>
45
- </div>
46
- <% end %>
47
- <% else %>
48
- <div class="sh-empty">
49
- <div class="sh-empty__icon">
50
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
51
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
52
- <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
53
- </svg>
54
- </div>
55
- <p>No active sessions found.</p>
56
- </div>
57
- <% end %>
58
- </div>
59
-
60
- <div class="sh-card__footer">
61
- <%= link_to "/", class: "sh-link" do %>
62
- ← Back to app
63
- <% end %>
64
- </div>
65
- </div>
66
- </div>
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Shakha
4
- module Auditable
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- after_action :log_sign_in
9
- after_action :log_sign_out
10
- after_action :log_token_exchange
11
- end
12
-
13
- private
14
-
15
- def log_sign_in
16
- return unless action_name == "callback" && response.successful? && @current_user
17
-
18
- ActiveSupport::Notifications.instrument("shakha.sign_in", {
19
- user_id: @current_user&.id,
20
- pairwise_sub: @current_user&.pairwise_sub,
21
- client_id: @current_client&.id,
22
- ip: request.remote_ip,
23
- user_agent: request.user_agent
24
- })
25
- end
26
-
27
- def log_sign_out
28
- return unless action_name == "destroy"
29
-
30
- ActiveSupport::Notifications.instrument("shakha.sign_out", {
31
- session_id: @current_session&.id,
32
- user_id: @current_session&.user_id,
33
- ip: request.remote_ip
34
- })
35
- end
36
-
37
- def log_token_exchange
38
- return unless action_name == "token"
39
-
40
- ActiveSupport::Notifications.instrument("shakha.token_exchange", {
41
- ip: request.remote_ip,
42
- user_agent: request.user_agent,
43
- success: response.successful?
44
- })
45
- end
46
- end
47
- end