shakha 0.3.0 → 0.9.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.
@@ -6,11 +6,11 @@ require "uri"
6
6
  module Shakha
7
7
  class AuthController < ApplicationController
8
8
  include PKCEMixin
9
+ include RateLimiter
9
10
 
10
- skip_before_action :verify_authenticity_token, only: [:callback]
11
+ skip_before_action :verify_authenticity_token, only: [ :callback ]
11
12
 
12
13
  def new
13
- @client = find_or_create_client
14
14
  @return_to = sanitize_return_to(params[:return_to])
15
15
  @providers = Shakha.config.providers
16
16
  end
@@ -19,11 +19,11 @@ module Shakha
19
19
  provider = resolve_provider
20
20
  pkce = create_pkce_bundle
21
21
 
22
- redirect_uri = "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
23
22
  auth_url = provider.authorize_url(
24
23
  state: pkce[:state],
25
24
  code_challenge: pkce[:challenge],
26
- redirect_uri: redirect_uri
25
+ redirect_uri: callback_redirect_uri(provider),
26
+ nonce: pkce[:nonce]
27
27
  )
28
28
 
29
29
  redirect_to auth_url, allow_other_host: true
@@ -36,26 +36,34 @@ module Shakha
36
36
  token_response = provider.exchange_code(
37
37
  code: params[:code],
38
38
  code_verifier: pkce_result[:verifier],
39
- redirect_uri: "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
39
+ redirect_uri: callback_redirect_uri(provider)
40
40
  )
41
41
 
42
- identity = provider.identity_from_response(token_response)
42
+ identity = provider.identity_from_response(token_response, expected_nonce: pkce_result[:nonce])
43
43
  user = find_or_create_user(provider.provider_name, identity)
44
44
  session_record = create_session(user)
45
45
  set_session_cookie(session_record)
46
- redirect_to build_return_url(pkce_result[:return_to], session_record)
46
+ ActiveSupport::Notifications.instrument(
47
+ "shakha.sign_in",
48
+ user_id: user.id, provider: provider.provider_name, ip: request.remote_ip
49
+ )
50
+ redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
47
51
 
48
52
  rescue PKCEError, OAuthError => e
49
53
  handle_auth_failure(e, pkce_result)
50
54
  end
51
55
 
52
56
  def destroy
53
- current_session&.destroy
57
+ if current_session
58
+ ActiveSupport::Notifications.instrument("shakha.sign_out", user_id: current_session.user_id)
59
+ current_session.destroy
60
+ end
54
61
  cookies.delete(:shakha_session_token)
55
62
 
56
- respond_to do |format|
57
- format.html { redirect_to params[:return_to].presence || "/" }
58
- format.json { render json: { status: "signed_out" } }
63
+ if request.format.json?
64
+ render json: { status: "signed_out" }
65
+ else
66
+ redirect_to params[:return_to].presence || "/"
59
67
  end
60
68
  end
61
69
 
@@ -66,26 +74,35 @@ module Shakha
66
74
  private
67
75
 
68
76
  def resolve_provider
69
- provider_name = (params[:provider] || :google).to_sym
70
- Shakha::Providers.resolve(provider_name)
77
+ name = (params[:provider] || :google).to_sym
78
+ raise ProviderNotFound, name.to_s unless Shakha.config.providers.include?(name)
79
+
80
+ Shakha::Providers.resolve(name)
81
+ end
82
+
83
+ # Built from the engine's actual mount point, so Shakha works no matter
84
+ # where the host app mounts it.
85
+ def callback_redirect_uri(provider)
86
+ origin = URI.parse(Shakha.config.app_origin)
87
+ callback_url(provider: provider.provider_name,
88
+ host: origin.host, port: origin.port, protocol: origin.scheme)
71
89
  end
72
90
 
73
91
  def find_or_create_user(provider_name, identity)
74
- Shakha::User.find_or_create_by!(
92
+ user = Shakha::User.find_or_initialize_by(
75
93
  provider: provider_name.to_s,
76
94
  uid: identity[:uid]
77
- ) do |user|
78
- user.client = find_or_create_client
79
- user.email = identity[:email]
80
- user.name = identity[:name]
81
- user.picture = identity[:picture]
82
- end
95
+ )
96
+ user.email = identity[:email]
97
+ user.name = identity[:name]
98
+ user.picture = identity[:picture]
99
+ user.save!
100
+ user
83
101
  end
84
102
 
85
103
  def create_session(user)
86
104
  Shakha::Session.create!(
87
105
  user: user,
88
- client: find_or_create_client,
89
106
  ip_address: request.remote_ip,
90
107
  user_agent: request.user_agent
91
108
  )
@@ -104,19 +121,30 @@ module Shakha
104
121
  def build_return_url(return_to, session_record)
105
122
  uri = URI.parse(return_to || "/")
106
123
  existing = URI.decode_www_form(uri.query || "").to_h
107
- existing["token"] = session_record.token
108
- existing["expires_at"] = session_record.expires_at.iso8601
124
+
125
+ if Shakha.config.redirect_token_delivery == :token
126
+ existing["token"] = session_record.token
127
+ existing["expires_at"] = session_record.expires_at.iso8601
128
+ else
129
+ existing["code"] = session_record.generate_exchange_code!
130
+ end
131
+
109
132
  uri.query = URI.encode_www_form(existing)
110
133
  uri.to_s
111
134
  end
112
135
 
113
136
  def handle_auth_failure(exception, pkce_result)
137
+ ActiveSupport::Notifications.instrument(
138
+ "shakha.auth_failure",
139
+ provider: params[:provider]&.to_sym, error: exception.class.name
140
+ )
114
141
  return_to = pkce_result&.dig(:return_to) || "/"
115
142
 
116
143
  if request.format.json? || api_request?
117
144
  render json: { error: user_facing_error(exception) }, status: :unauthorized
118
145
  else
119
- redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}"
146
+ redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}",
147
+ allow_other_host: true
120
148
  end
121
149
  end
122
150
 
@@ -145,14 +173,6 @@ module Shakha
145
173
  Shakha.config.allowed_redirect_origins&.include?(origin) || false
146
174
  end
147
175
 
148
- def find_or_create_client
149
- origin = request.origin || Shakha.config.app_origin
150
- origin_uri = URI.parse(origin).origin
151
- Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
152
- client.name = URI.parse(origin).host
153
- end
154
- end
155
-
156
176
  def user_facing_error(exception)
157
177
  case exception
158
178
  when PKCEError then "Authentication failed. Please try again."
@@ -2,6 +2,19 @@
2
2
 
3
3
  module Shakha
4
4
  class SessionController < ApplicationController
5
+ skip_before_action :verify_authenticity_token, only: :exchange
6
+
7
+ # POST /session/exchange — swap a one-time code for the session token.
8
+ def exchange
9
+ session_record = Shakha::Session.exchange(params[:code])
10
+ if session_record
11
+ render json: { token: session_record.token,
12
+ expires_at: session_record.expires_at.iso8601 }
13
+ else
14
+ render json: { error: "Invalid or expired code" }, status: :unauthorized
15
+ end
16
+ end
17
+
5
18
  def show
6
19
  unless signed_in?
7
20
  return render json: { error: "Authentication required" }, status: :unauthorized
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class SessionsController < ApplicationController
5
+ before_action :authenticate!
6
+
7
+ # GET /sessions — the current user's active sessions, newest first.
8
+ def index
9
+ sessions = Shakha::Session.for(current_user).map do |session_record|
10
+ {
11
+ id: session_record.id,
12
+ ip_address: session_record.ip_address,
13
+ user_agent: session_record.user_agent,
14
+ created_at: session_record.created_at.iso8601,
15
+ current: session_record.id == current_session.id
16
+ }
17
+ end
18
+
19
+ render json: { sessions: sessions }
20
+ end
21
+
22
+ # DELETE /sessions/:id — revoke one of the current user's sessions.
23
+ def destroy
24
+ session_record = Shakha::Session.for(current_user).find_by(id: params[:id])
25
+ return render json: { error: "Session not found" }, status: :not_found unless session_record
26
+
27
+ session_record.destroy
28
+ cookies.delete(:shakha_session_token) if session_record.id == current_session.id
29
+
30
+ render json: { status: "revoked" }
31
+ end
32
+ end
33
+ end
@@ -5,12 +5,17 @@ module Shakha
5
5
  self.table_name = "shakha_sessions"
6
6
 
7
7
  belongs_to :user, class_name: "Shakha::User", optional: true
8
- belongs_to :client, class_name: "Shakha::Client"
9
8
 
10
9
  before_create :generate_token
11
10
 
12
11
  scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
13
12
 
13
+ def self.for(user)
14
+ active.where(user: user).order(created_at: :desc, id: :desc)
15
+ end
16
+
17
+ EXCHANGE_CODE_TTL = 60.seconds
18
+
14
19
  def expired?
15
20
  created_at < Shakha.config.session_lifetime.ago
16
21
  end
@@ -19,10 +24,34 @@ module Shakha
19
24
  created_at + Shakha.config.session_lifetime
20
25
  end
21
26
 
27
+ # Issues a short-lived, single-use code the frontend swaps for the session
28
+ # token, so the token itself never travels in a redirect URL.
29
+ def generate_exchange_code!
30
+ update_columns(
31
+ exchange_code: SecureRandom.urlsafe_base64(32),
32
+ exchange_code_expires_at: EXCHANGE_CODE_TTL.from_now
33
+ )
34
+ exchange_code
35
+ end
36
+
37
+ # Redeems a code for its session, atomically clearing it so a code can be
38
+ # used at most once even under concurrent requests.
39
+ def self.exchange(code)
40
+ return nil if code.blank?
41
+
42
+ session = active.where(exchange_code: code)
43
+ .where("exchange_code_expires_at > ?", Time.current).first
44
+ return nil unless session
45
+
46
+ claimed = where(id: session.id).where.not(exchange_code: nil)
47
+ .update_all(exchange_code: nil, exchange_code_expires_at: nil)
48
+ claimed == 1 ? session : nil
49
+ end
50
+
22
51
  private
23
52
 
24
53
  def generate_token
25
54
  self.token ||= SecureRandom.urlsafe_base64(32)
26
55
  end
27
56
  end
28
- end
57
+ end
@@ -4,12 +4,10 @@ module Shakha
4
4
  class User < ::ApplicationRecord
5
5
  self.table_name = "shakha_users"
6
6
 
7
- belongs_to :client, class_name: "Shakha::Client"
8
7
  has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
9
8
 
10
9
  validates :provider, presence: true
11
10
  validates :uid, presence: true
12
11
  validates :uid, uniqueness: { scope: :provider }
13
- validates :email, uniqueness: { scope: :client_id }, allow_blank: true
14
12
  end
15
- end
13
+ end
@@ -1,4 +1,4 @@
1
- <% content_for :title, "Sign in — #{@client&.name || 'Shakha'}" %>
1
+ <% content_for :title, "Sign in — #{Shakha.config.app_name}" %>
2
2
 
3
3
  <div class="sh-layout sh-animate-fade">
4
4
  <div class="sh-card sh-animate-slide">
@@ -6,18 +6,18 @@
6
6
  <div class="sh-brand">
7
7
  <div class="sh-brand__icon">S</div>
8
8
  <div>
9
- <div class="sh-brand__name"><%= @client&.name || "Shakha" %></div>
9
+ <div class="sh-brand__name"><%= Shakha.config.app_name %></div>
10
10
  <div class="sh-brand__subtitle">Secure authentication</div>
11
11
  </div>
12
12
  </div>
13
13
 
14
14
  <h1 class="sh-heading sh-heading--center">Welcome back</h1>
15
- <p class="sh-subheading">Sign in to continue to <%= @client&.name || "your app" %></p>
15
+ <p class="sh-subheading">Sign in to continue to <%= Shakha.config.app_name %></p>
16
16
  </div>
17
17
 
18
18
  <div class="sh-card__body">
19
19
  <% @providers.each do |provider| %>
20
- <%= link_to shakha.send("#{provider}_authorize_path"),
20
+ <%= link_to "/auth/shakha/#{provider}",
21
21
  class: "sh-btn sh-btn--#{provider}",
22
22
  data: { turbo: false } do %>
23
23
  Continue with <%= provider.to_s.titleize %>
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title><%= yield(:title).presence || "Shakha" %></title>
7
- <%= stylesheet_link_tag "shakha", "data-turbo-track": "reload" %>
7
+ <style><%= Shakha::Engine.root.join("app/assets/stylesheets/shakha.css").read.html_safe %></style>
8
8
  <%= csrf_meta_tags %>
9
9
  <meta name="theme-color" content="#0f0f23" media="(prefers-color-scheme: dark)">
10
10
  <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
@@ -0,0 +1,103 @@
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
+ BUILT_IN_PROVIDERS = %w[google github].freeze
12
+
13
+ desc "Installs Shakha — headless OAuth broker for Rails"
14
+
15
+ class_option :providers, type: :string, default: "google,github",
16
+ desc: "Comma-separated providers to configure (#{BUILT_IN_PROVIDERS.join(', ')})"
17
+
18
+ def validate_providers
19
+ unknown = providers - BUILT_IN_PROVIDERS
20
+ raise Thor::Error, "Unknown provider(s): #{unknown.join(', ')}. Choose from: #{BUILT_IN_PROVIDERS.join(', ')}" if unknown.any?
21
+ raise Thor::Error, "--providers must name at least one provider" if providers.empty?
22
+ end
23
+
24
+ def copy_migration
25
+ migration_template "create_shakha_tables.rb.erb", "db/migrate/create_shakha_tables.rb"
26
+ end
27
+
28
+ def copy_initializer
29
+ path = "config/initializers/shakha.rb"
30
+ if File.exist?(File.join(destination_root, path)) && !options[:force]
31
+ say_status :exist, path, :blue
32
+ return
33
+ end
34
+
35
+ template "shakha.rb.erb", path
36
+ end
37
+
38
+ def inject_application_controller
39
+ path = "app/controllers/application_controller.rb"
40
+ full_path = File.join(destination_root, path)
41
+ return unless File.exist?(full_path)
42
+ return if File.read(full_path).include?("Shakha::ControllerHelpers")
43
+
44
+ inject_into_class path, "ApplicationController", " include Shakha::ControllerHelpers\n"
45
+ say_status :insert, "ApplicationController -> include Shakha::ControllerHelpers", :green
46
+ end
47
+
48
+ def enable_cookies_for_api_mode
49
+ return unless api_only_app?
50
+ return if File.read(File.join(destination_root, "config/application.rb")).include?("ActionDispatch::Cookies")
51
+
52
+ application "config.middleware.use ActionDispatch::Cookies"
53
+ say_status :insert, "config/application.rb -> ActionDispatch::Cookies (API mode)", :green
54
+ end
55
+
56
+ def print_post_install
57
+ origin = Shakha.config.app_origin || "http://localhost:3000"
58
+
59
+ say ""
60
+ say " Shakha installed!", :green
61
+ say " #{'─' * 50}", :green
62
+ say ""
63
+ say " 1. Set environment variables:", :yellow
64
+ providers.each do |provider|
65
+ say " #{provider.upcase}_CLIENT_ID / #{provider.upcase}_CLIENT_SECRET", :cyan
66
+ end
67
+ say ""
68
+ say " 2. For SPA: set ALLOWED_REDIRECT_ORIGINS", :yellow
69
+ say ""
70
+ say " 3. Run migrations:", :yellow
71
+ say " bin/rails db:migrate", :cyan
72
+ say ""
73
+ say " 4. OAuth app redirect / callback URLs:", :yellow
74
+ providers.each do |provider|
75
+ say " #{origin}/auth/shakha/#{provider}/callback", :cyan
76
+ end
77
+ say " #{'─' * 50}", :green
78
+ say ""
79
+ say " Tell your frontend dev:", :cyan
80
+ say " Sign in: #{origin}/auth/shakha/#{providers.first}"
81
+ say " Session: GET #{origin}/auth/shakha/session"
82
+ say " Auth: Authorization: Bearer <token>"
83
+ say " Sign out: DELETE #{origin}/auth/shakha/sign_out"
84
+ say ""
85
+ end
86
+
87
+ private
88
+
89
+ def providers
90
+ @providers ||= options[:providers].to_s.split(",").map { |p| p.strip.downcase }.reject(&:empty?).uniq
91
+ end
92
+
93
+ def migration_version
94
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
95
+ end
96
+
97
+ def api_only_app?
98
+ path = File.join(destination_root, "config/application.rb")
99
+ File.exist?(path) && File.read(path).include?("config.api_only = true")
100
+ end
101
+ end
102
+ end
103
+ 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,42 @@
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
+ <% if providers.include?("google") -%>
23
+ # Google OAuth
24
+ config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
25
+ config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
26
+
27
+ <% end -%>
28
+ <% if providers.include?("github") -%>
29
+ # GitHub OAuth
30
+ config.github_client_id = ENV["GITHUB_CLIENT_ID"]
31
+ config.github_client_secret = ENV["GITHUB_CLIENT_SECRET"]
32
+
33
+ <% end -%>
34
+ # Enabled providers
35
+ config.providers = [ <%= providers.map { |p| ":#{p}" }.join(", ") %> ]
36
+
37
+ # Session lifetime (default: 30 days)
38
+ # config.session_lifetime = 30.days
39
+
40
+ # Rate limiting (default: false)
41
+ # config.rate_limiting_enabled = true
42
+ end
data/lib/shakha/config.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Shakha
4
4
  class Config
5
5
  attr_accessor :app_origin,
6
+ :app_name,
6
7
  :google_client_id,
7
8
  :google_client_secret,
8
9
  :github_client_id,
@@ -10,12 +11,15 @@ module Shakha
10
11
  :providers,
11
12
  :session_lifetime,
12
13
  :rate_limiting_enabled,
13
- :allowed_redirect_origins
14
+ :allowed_redirect_origins,
15
+ :redirect_token_delivery
14
16
 
15
17
  def initialize
18
+ @app_name = "Shakha"
16
19
  @session_lifetime = 30.days
17
20
  @rate_limiting_enabled = false
18
- @providers = [:google]
21
+ @providers = [ :google ]
22
+ @redirect_token_delivery = :exchange_code
19
23
  end
20
24
  end
21
25
  end
@@ -2,12 +2,21 @@
2
2
 
3
3
  module Shakha
4
4
  module ConfigValidator
5
+ BUILT_IN_CREDENTIALS = {
6
+ google: %i[google_client_id google_client_secret],
7
+ github: %i[github_client_id github_client_secret]
8
+ }.freeze
9
+
5
10
  class << self
6
11
  def validate!(config)
7
12
  missing = []
8
13
  missing << "APP_ORIGIN" unless config.app_origin.present?
9
- missing << "GOOGLE_CLIENT_ID" unless config.google_client_id.present?
10
- missing << "GOOGLE_CLIENT_SECRET" unless config.google_client_secret.present?
14
+
15
+ Array(config.providers).each do |provider|
16
+ BUILT_IN_CREDENTIALS.fetch(provider.to_sym, []).each do |attr|
17
+ missing << attr.to_s.upcase unless config.public_send(attr).present?
18
+ end
19
+ end
11
20
 
12
21
  unless missing.empty?
13
22
  message = "Shakha: missing required configuration: #{missing.join(', ')}"
@@ -7,7 +7,7 @@ module Shakha
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- helper_method :current_user, :current_session, :signed_in?
10
+ helper_method :current_session, :current_user, :signed_in? if respond_to?(:helper_method)
11
11
  end
12
12
 
13
13
  private
@@ -28,13 +28,15 @@ module Shakha
28
28
  def authenticate!
29
29
  return if signed_in?
30
30
 
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 }
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)}"
34
35
  end
35
36
  end
36
37
 
37
38
  def find_session_from_cookie
39
+ return unless respond_to?(:cookies)
38
40
  token = cookies.encrypted[:shakha_session_token]
39
41
  return unless token
40
42
  Shakha::Session.active.find_by(token: token)
data/lib/shakha/engine.rb CHANGED
@@ -11,13 +11,18 @@ module Shakha
11
11
  routes do
12
12
  root to: "auth#new"
13
13
 
14
- get ":provider/authorize" => "auth#authorize"
15
- get ":provider/callback" => "auth#callback"
16
- delete "sign_out" => "auth#destroy"
17
- get "error" => "auth#error"
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
+ get "sessions" => "sessions#index", defaults: { format: :json }
19
+ delete "sessions/:id" => "sessions#destroy", defaults: { format: :json }, as: :revoke_session
20
+ delete "sign_out" => "auth#destroy"
21
+ get "error" => "auth#error"
18
22
 
19
- get "session" => "session#show"
20
- get "session/check" => "session#check"
23
+ # Dynamic provider routes
24
+ get ":provider" => "auth#authorize", as: :authorize
25
+ get ":provider/callback" => "auth#callback", as: :callback
21
26
  end
22
27
  end
23
- end
28
+ end
@@ -8,6 +8,7 @@ module Shakha
8
8
 
9
9
  included do
10
10
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
11
+ rescue_from Shakha::ProviderNotFound, with: :provider_not_found
11
12
  rescue_from Shakha::PKCEError, with: :bad_request
12
13
  rescue_from Shakha::OAuthError, with: :bad_gateway
13
14
  end
@@ -18,6 +19,10 @@ module Shakha
18
19
  render json: { error: exception.message }, status: :not_found
19
20
  end
20
21
 
22
+ def provider_not_found(_exception)
23
+ render json: { error: "Unknown provider" }, status: :not_found
24
+ end
25
+
21
26
  def unauthorized(exception)
22
27
  render json: { error: "Unauthorized" }, status: :unauthorized
23
28
  end
@@ -31,4 +36,4 @@ module Shakha
31
36
  render json: { error: "Authentication service unavailable" }, status: :bad_gateway
32
37
  end
33
38
  end
34
- 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