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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +153 -92
  4. data/SECURITY.md +56 -0
  5. data/app/controllers/shakha/application_controller.rb +3 -4
  6. data/app/controllers/shakha/auth_controller.rb +103 -196
  7. data/app/controllers/shakha/session_controller.rb +27 -59
  8. data/app/models/shakha/session.rb +27 -7
  9. data/app/models/shakha/user.rb +4 -8
  10. data/app/views/shakha/auth/new.html.erb +9 -21
  11. data/app/views/shakha/layouts/shakha.html.erb +1 -1
  12. data/lib/generators/shakha/install/install_generator.rb +84 -0
  13. data/lib/generators/shakha/install/templates/create_shakha_tables.rb.erb +27 -0
  14. data/lib/generators/shakha/install/templates/shakha.rb.erb +39 -0
  15. data/lib/shakha/config.rb +10 -30
  16. data/lib/shakha/config_validator.rb +1 -2
  17. data/lib/shakha/controller_helpers.rb +16 -33
  18. data/lib/shakha/engine.rb +10 -17
  19. data/lib/shakha/error_handler.rb +8 -4
  20. data/lib/shakha/pkce.rb +5 -4
  21. data/lib/shakha/providers/base.rb +27 -0
  22. data/lib/shakha/providers/github.rb +101 -0
  23. data/lib/shakha/providers/google.rb +113 -0
  24. data/lib/shakha/providers.rb +19 -0
  25. data/lib/shakha/rate_limiter.rb +5 -5
  26. data/lib/shakha/version.rb +2 -2
  27. data/lib/shakha.rb +4 -29
  28. metadata +34 -26
  29. data/app/controllers/shakha/jwks_controller.rb +0 -10
  30. data/app/controllers/shakha/openid_controller.rb +0 -21
  31. data/app/models/shakha/client.rb +0 -20
  32. data/app/views/shakha/auth/sessions.html.erb +0 -66
  33. data/lib/generators/shakha/install_generator.rb +0 -25
  34. data/lib/generators/shakha/templates/initializer.rb.erb +0 -29
  35. data/lib/generators/shakha/templates/migration.rb.erb +0 -45
  36. data/lib/shakha/auditable.rb +0 -47
  37. data/lib/shakha/jwt_handler.rb +0 -127
  38. data/lib/shakha/middleware.rb +0 -49
  39. 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
- :service_url,
7
- :service_secret,
6
+ :app_name,
8
7
  :google_client_id,
9
8
  :google_client_secret,
10
- :issuer,
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
- end
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 << "SHAKHA_APP_ORIGIN" unless config.app_origin.present?
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
- 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
+ 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 authenticate_from_cookie
43
- find_session_by_token(session_token)
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 bearer_token
47
- pattern = /^Bearer /
45
+ def find_session_from_bearer
48
46
  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)
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
- 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
+ # 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
- get ".well-known/jwks.json" => "jwks#show"
30
- get ".well-known/openid-configuration" => "openid#configuration"
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
@@ -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::JWTError, with: :unauthorized
11
+ rescue_from Shakha::ProviderNotFound, with: :provider_not_found
12
12
  rescue_from Shakha::PKCEError, with: :bad_request
13
- rescue_from Shakha::GoogleOAuthError, with: :bad_gateway
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] Google OAuth error: #{exception.message}")
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, padding: false)
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
- return_to = params[:return_to] || "/"
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
- class GoogleOAuthError < StandardError; end
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