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
@@ -6,199 +6,102 @@ require "uri"
6
6
  module Shakha
7
7
  class AuthController < ApplicationController
8
8
  include PKCEMixin
9
- include Auditable
9
+ include RateLimiter
10
10
 
11
- skip_before_action :verify_authenticity_token, only: [:callback, :token]
11
+ skip_before_action :verify_authenticity_token, only: [ :callback ]
12
12
 
13
13
  def new
14
- @client = find_or_create_client
15
14
  @return_to = sanitize_return_to(params[:return_to])
15
+ @providers = Shakha.config.providers
16
16
  end
17
17
 
18
18
  def authorize
19
- params[:return_to] = sanitize_return_to(params[:return_to])
19
+ provider = resolve_provider
20
20
  pkce = create_pkce_bundle
21
- @client = find_or_create_client
22
21
 
23
- google_auth_url = build_google_auth_url(pkce)
22
+ auth_url = provider.authorize_url(
23
+ state: pkce[:state],
24
+ code_challenge: pkce[:challenge],
25
+ redirect_uri: callback_redirect_uri(provider),
26
+ nonce: pkce[:nonce]
27
+ )
24
28
 
25
- redirect_to google_auth_url, allow_other_host: true
29
+ redirect_to auth_url, allow_other_host: true
26
30
  end
27
31
 
28
32
  def callback
33
+ provider = resolve_provider
29
34
  pkce_result = verify_pkce!(params[:state])
30
- exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to], pkce_result[:nonce])
31
- rescue PKCEError, GoogleOAuthError => e
32
- ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
33
- reason: e.class.name,
34
- ip: request.remote_ip
35
- })
36
- Rails.logger.warn("[Shakha] Auth error: #{e.class}: #{e.message}")
37
- redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(user_facing_error(e))}"
38
- end
39
-
40
- def token
41
- code = params[:code]
42
- verifier = params[:code_verifier]
43
-
44
- raise PKCEError, "Missing code" unless code
45
- raise PKCEError, "Missing code_verifier" unless verifier
46
-
47
- id_token = exchange_code_for_id_token(code, verifier)
48
-
49
- render json: {
50
- id_token: id_token,
51
- pairwise_sub: id_token_payload(id_token)[:sub],
52
- expires_in: 24.hours.to_i
53
- }
54
- rescue PKCEError, JWTError, GoogleOAuthError => e
55
- render json: { error: e.message }, status: :unauthorized
56
- end
57
-
58
- def error
59
- @message = params[:message] || "Authentication failed"
60
- end
61
-
62
- private
63
-
64
- def sanitize_return_to(raw)
65
- return "/" if raw.blank?
66
-
67
- uri = URI.parse(raw)
68
- app_host = URI.parse(Shakha.config.app_origin).host
69
35
 
70
- # Must have a path
71
- return "/" unless uri.path.present? && uri.path.start_with?("/")
72
-
73
- # If external host, must be in allowed origins
74
- if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
75
- return "/"
76
- end
77
-
78
- raw
79
- rescue URI::InvalidURIError
80
- "/"
81
- end
36
+ token_response = provider.exchange_code(
37
+ code: params[:code],
38
+ code_verifier: pkce_result[:verifier],
39
+ redirect_uri: callback_redirect_uri(provider)
40
+ )
82
41
 
83
- def allowed_origin?(origin)
84
- Shakha.config.allowed_redirect_origins&.include?(origin) || false
85
- end
42
+ identity = provider.identity_from_response(token_response, expected_nonce: pkce_result[:nonce])
43
+ user = find_or_create_user(provider.provider_name, identity)
44
+ session_record = create_session(user)
45
+ set_session_cookie(session_record)
46
+ redirect_to build_return_url(pkce_result[:return_to], session_record), allow_other_host: true
86
47
 
87
- def app_origin_host
88
- URI.parse(Shakha.config.app_origin).host
48
+ rescue PKCEError, OAuthError => e
49
+ handle_auth_failure(e, pkce_result)
89
50
  end
90
51
 
91
- def client_origin_host
92
- URI.parse(Shakha.config.service_base_url).host
93
- rescue URI::InvalidURIError
94
- nil
95
- end
52
+ def destroy
53
+ current_session&.destroy
54
+ cookies.delete(:shakha_session_token)
96
55
 
97
- def user_facing_error(exception)
98
- case exception
99
- when PKCEError
100
- "Authentication failed. Please try again."
101
- when GoogleOAuthError
102
- "Unable to sign in with Google. Please try again later."
56
+ if request.format.json?
57
+ render json: { status: "signed_out" }
103
58
  else
104
- "An unexpected error occurred. Please try again."
59
+ redirect_to params[:return_to].presence || "/"
105
60
  end
106
61
  end
107
62
 
108
- def find_or_create_client
109
- origin = request.origin || Shakha.config.app_origin
110
- origin_uri = URI.parse(origin).origin
111
-
112
- if Shakha.config.embedded?
113
- Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
114
- client.name = URI.parse(origin).host
115
- end
116
- else
117
- Shakha::Client.find_by!(origin: origin_uri)
118
- end
119
- rescue ActiveRecord::RecordNotFound
120
- raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
63
+ def error
64
+ @message = params[:message] || "Authentication failed"
121
65
  end
122
66
 
123
- def build_google_auth_url(pkce)
124
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
125
- base_url = Shakha.config.service_base_url || "http://localhost:3000"
126
- redirect_uri = "#{base_url}/auth/shakha/callback"
67
+ private
127
68
 
128
- scopes = ["openid", "email", "profile"].join(" ")
129
- scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
69
+ def resolve_provider
70
+ name = (params[:provider] || :google).to_sym
71
+ raise ProviderNotFound, name.to_s unless Shakha.config.providers.include?(name)
130
72
 
131
- params = {
132
- client_id: client_id,
133
- redirect_uri: redirect_uri,
134
- response_type: "code",
135
- scope: scopes,
136
- code_challenge: pkce[:challenge],
137
- code_challenge_method: "S256",
138
- state: pkce[:state],
139
- nonce: pkce[:nonce],
140
- access_type: "offline",
141
- prompt: "consent"
142
- }
73
+ Shakha::Providers.resolve(name)
74
+ end
143
75
 
144
- URI.parse("https://accounts.google.com/o/oauth2/v2/auth").tap do |uri|
145
- uri.query = URI.encode_www_form(params)
146
- end.to_s
76
+ # Built from the engine's actual mount point, so Shakha works no matter
77
+ # where the host app mounts it.
78
+ def callback_redirect_uri(provider)
79
+ origin = URI.parse(Shakha.config.app_origin)
80
+ callback_url(provider: provider.provider_name,
81
+ host: origin.host, port: origin.port, protocol: origin.scheme)
147
82
  end
148
83
 
149
- def exchange_code_for_tokens(code, verifier, return_to = "/", expected_nonce = nil)
150
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
151
- client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
152
- base_url = Shakha.config.service_base_url || "http://localhost:3000"
153
- redirect_uri = "#{base_url}/auth/shakha/callback"
154
-
155
- response = http_post(
156
- "https://oauth2.googleapis.com/token",
157
- {
158
- code: code,
159
- client_id: client_id,
160
- client_secret: client_secret,
161
- redirect_uri: redirect_uri,
162
- grant_type: "authorization_code",
163
- code_verifier: verifier
164
- }
84
+ def find_or_create_user(provider_name, identity)
85
+ user = Shakha::User.find_or_initialize_by(
86
+ provider: provider_name.to_s,
87
+ uid: identity[:uid]
165
88
  )
166
-
167
- tokens = JSON.parse(response.body)
168
- id_token = tokens["id_token"]
169
- access_token = tokens["access_token"]
170
-
171
- raise GoogleOAuthError, "No id_token received" unless id_token
172
-
173
- payload = decode_id_token(id_token)
174
- google_sub = payload["sub"]
175
-
176
- # Verify nonce (OIDC replay protection)
177
- if expected_nonce && payload["nonce"] != expected_nonce
178
- raise GoogleOAuthError, "Nonce mismatch"
179
- end
180
-
181
- client = find_or_create_client
182
- pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
183
-
184
- user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
185
-
186
- if payload["email"]
187
- user.assign_attributes(
188
- email: payload["email"],
189
- name: payload["name"],
190
- picture: payload["picture"]
191
- )
192
- end
89
+ user.email = identity[:email]
90
+ user.name = identity[:name]
91
+ user.picture = identity[:picture]
193
92
  user.save!
93
+ user
94
+ end
194
95
 
195
- session_record = Shakha::Session.create!(
96
+ def create_session(user)
97
+ Shakha::Session.create!(
196
98
  user: user,
197
- client: client,
198
99
  ip_address: request.remote_ip,
199
100
  user_agent: request.user_agent
200
101
  )
102
+ end
201
103
 
104
+ def set_session_cookie(session_record)
202
105
  cookies.encrypted[:shakha_session_token] = {
203
106
  value: session_record.token,
204
107
  httponly: true,
@@ -206,61 +109,65 @@ module Shakha
206
109
  same_site: :lax,
207
110
  expires: Shakha.config.session_lifetime.from_now
208
111
  }
209
-
210
- redirect_to sanitize_return_to(return_to)
211
112
  end
212
113
 
213
- def exchange_code_for_id_token(code, verifier)
214
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
215
- client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
216
- redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
217
-
218
- response = http_post(
219
- "https://oauth2.googleapis.com/token",
220
- {
221
- code: code,
222
- client_id: client_id,
223
- client_secret: client_secret,
224
- redirect_uri: redirect_uri,
225
- grant_type: "authorization_code",
226
- code_verifier: verifier
227
- }
228
- )
114
+ def build_return_url(return_to, session_record)
115
+ uri = URI.parse(return_to || "/")
116
+ existing = URI.decode_www_form(uri.query || "").to_h
117
+
118
+ if Shakha.config.redirect_token_delivery == :token
119
+ existing["token"] = session_record.token
120
+ existing["expires_at"] = session_record.expires_at.iso8601
121
+ else
122
+ existing["code"] = session_record.generate_exchange_code!
123
+ end
229
124
 
230
- tokens = JSON.parse(response.body)
231
- tokens["id_token"] || raise(GoogleOAuthError, "No id_token in response")
125
+ uri.query = URI.encode_www_form(existing)
126
+ uri.to_s
232
127
  end
233
128
 
234
- def id_token_payload(id_token)
235
- JWT.decode(id_token, nil, false)[0]
129
+ def handle_auth_failure(exception, pkce_result)
130
+ return_to = pkce_result&.dig(:return_to) || "/"
131
+
132
+ if request.format.json? || api_request?
133
+ render json: { error: user_facing_error(exception) }, status: :unauthorized
134
+ else
135
+ redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}",
136
+ allow_other_host: true
137
+ end
236
138
  end
237
139
 
238
- def decode_id_token(id_token)
239
- JWT.decode(id_token, nil, false)[0]
140
+ def api_request?
141
+ request.headers["Accept"]&.include?("application/json")
240
142
  end
241
143
 
242
- def http_post(url, body)
243
- uri = URI.parse(url)
244
- http = Net::HTTP.new(uri.host, uri.port)
245
- http.use_ssl = uri.scheme == "https"
246
- http.open_timeout = 5
247
- http.read_timeout = 10
144
+ def sanitize_return_to(raw)
145
+ return "/" if raw.blank?
248
146
 
249
- request = Net::HTTP::Post.new(uri.request_uri)
250
- request["Content-Type"] = "application/x-www-form-urlencoded"
251
- request.body = URI.encode_www_form(body)
147
+ uri = URI.parse(raw)
148
+ app_host = URI.parse(Shakha.config.app_origin).host
252
149
 
253
- response = http.request(request)
150
+ return "/" unless uri.path.present? && uri.path.start_with?("/")
254
151
 
255
- unless response.is_a?(Net::HTTPSuccess)
256
- Rails.logger.error("[Shakha] Google API error: HTTP #{response.code} — #{response.body.truncate(500)}")
257
- raise GoogleOAuthError, "Google returned HTTP #{response.code}"
152
+ if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
153
+ return "/"
258
154
  end
259
155
 
260
- response
261
- rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
262
- Rails.logger.error("[Shakha] Network error contacting Google: #{e.message}")
263
- raise GoogleOAuthError, "Unable to reach Google authentication service"
156
+ raw
157
+ rescue URI::InvalidURIError
158
+ "/"
159
+ end
160
+
161
+ def allowed_origin?(origin)
162
+ Shakha.config.allowed_redirect_origins&.include?(origin) || false
163
+ end
164
+
165
+ def user_facing_error(exception)
166
+ case exception
167
+ when PKCEError then "Authentication failed. Please try again."
168
+ when OAuthError then "Unable to sign in. Please try again later."
169
+ else "An unexpected error occurred. Please try again."
170
+ end
264
171
  end
265
172
  end
266
- end
173
+ end
@@ -2,34 +2,35 @@
2
2
 
3
3
  module Shakha
4
4
  class SessionController < ApplicationController
5
- include Auditable
6
- skip_before_action :verify_authenticity_token, only: [:check]
7
-
8
- def index
9
- return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
10
-
11
- sessions = current_user.sessions.active.order(created_at: :desc)
12
-
13
- render json: {
14
- current_token: current_session.token,
15
- sessions: sessions.map { |s|
16
- {
17
- id: s.id,
18
- token: s.token,
19
- created_at: s.created_at.iso8601,
20
- expires_at: s.expires_at.iso8601,
21
- current: s.token == current_session.token
22
- }
23
- }
24
- }
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
25
16
  end
26
17
 
27
18
  def show
19
+ unless signed_in?
20
+ return render json: { error: "Authentication required" }, status: :unauthorized
21
+ end
22
+
28
23
  render json: {
29
- user_id: current_user&.pairwise_sub,
30
- email: current_user&.email,
31
- name: current_user&.name,
32
- expires_at: current_session&.expires_at&.iso8601
24
+ user: {
25
+ id: current_user.id,
26
+ email: current_user.email,
27
+ name: current_user.name,
28
+ picture: current_user.picture,
29
+ provider: current_user.provider
30
+ },
31
+ session: {
32
+ expires_at: current_session.expires_at.iso8601
33
+ }
33
34
  }
34
35
  end
35
36
 
@@ -37,41 +38,8 @@ module Shakha
37
38
  if signed_in?
38
39
  render json: { status: "active" }
39
40
  else
40
- render json: {
41
- status: "login_required",
42
- reason: "no_session"
43
- }, status: :unauthorized
44
- end
45
- end
46
-
47
- def destroy
48
- current_session&.destroy
49
- cookies.delete(:shakha_session_token)
50
-
51
- respond_to do |format|
52
- format.html { redirect_to params[:return_to].presence || "/" }
53
- format.json { render json: { status: "signed_out" } }
41
+ render json: { status: "expired" }, status: :unauthorized
54
42
  end
55
43
  end
56
-
57
- def list
58
- return redirect_to "/auth/shakha" unless signed_in?
59
-
60
- @sessions = current_user.sessions.active.order(created_at: :desc)
61
- @current_token = current_session&.token
62
- end
63
-
64
- def revoke
65
- return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
66
-
67
- session = current_user.sessions.find(params[:id])
68
- session.destroy
69
-
70
- cookies.delete(:shakha_session_token) if session.token == current_session&.token
71
-
72
- log_session_revoked(session)
73
-
74
- render json: { status: "revoked" }
75
- end
76
44
  end
77
- end
45
+ end
@@ -5,13 +5,13 @@ 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
- before_create :generate_jti
12
10
 
13
11
  scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
14
12
 
13
+ EXCHANGE_CODE_TTL = 60.seconds
14
+
15
15
  def expired?
16
16
  created_at < Shakha.config.session_lifetime.ago
17
17
  end
@@ -20,14 +20,34 @@ module Shakha
20
20
  created_at + Shakha.config.session_lifetime
21
21
  end
22
22
 
23
+ # Issues a short-lived, single-use code the frontend swaps for the session
24
+ # token, so the token itself never travels in a redirect URL.
25
+ def generate_exchange_code!
26
+ update_columns(
27
+ exchange_code: SecureRandom.urlsafe_base64(32),
28
+ exchange_code_expires_at: EXCHANGE_CODE_TTL.from_now
29
+ )
30
+ exchange_code
31
+ end
32
+
33
+ # Redeems a code for its session, atomically clearing it so a code can be
34
+ # used at most once even under concurrent requests.
35
+ def self.exchange(code)
36
+ return nil if code.blank?
37
+
38
+ session = active.where(exchange_code: code)
39
+ .where("exchange_code_expires_at > ?", Time.current).first
40
+ return nil unless session
41
+
42
+ claimed = where(id: session.id).where.not(exchange_code: nil)
43
+ .update_all(exchange_code: nil, exchange_code_expires_at: nil)
44
+ claimed == 1 ? session : nil
45
+ end
46
+
23
47
  private
24
48
 
25
49
  def generate_token
26
50
  self.token ||= SecureRandom.urlsafe_base64(32)
27
51
  end
28
-
29
- def generate_jti
30
- self.jti ||= SecureRandom.uuid
31
- end
32
52
  end
33
- end
53
+ end
@@ -4,14 +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
- validates :pairwise_sub, presence: true
11
- validates :email, uniqueness: { scope: :client_id }, allow_blank: true
12
-
13
- def can_access?(resource)
14
- true
15
- end
9
+ validates :provider, presence: true
10
+ validates :uid, presence: true
11
+ validates :uid, uniqueness: { scope: :provider }
16
12
  end
17
- 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,35 +6,23 @@
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
- <%= link_to shakha.authorize_path(request_pii: 1),
20
- class: "sh-btn sh-btn--google",
21
- data: { turbo: false } do %>
22
- <svg class="sh-btn__icon" viewBox="0 0 18 18" aria-hidden="true">
23
- <path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
24
- <path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
25
- <path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
26
- <path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
27
- </svg>
28
- Continue with Google
19
+ <% @providers.each do |provider| %>
20
+ <%= link_to "/auth/shakha/#{provider}",
21
+ class: "sh-btn sh-btn--#{provider}",
22
+ data: { turbo: false } do %>
23
+ Continue with <%= provider.to_s.titleize %>
24
+ <% end %>
29
25
  <% end %>
30
-
31
- <div class="sh-divider">or</div>
32
-
33
- <p class="sh-text-center" style="color: var(--sh-text-tertiary); font-size: var(--sh-text-sm);">
34
- By signing in, you agree to our
35
- <a href="#">Terms</a> and
36
- <a href="#">Privacy Policy</a>.
37
- </p>
38
26
  </div>
39
27
 
40
28
  <div class="sh-card__footer">
@@ -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)">