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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dab553b64eb62de5d3a4225c5a4aa4fb470f8c6cf4aeea518f549dd664b7e67a
4
+ data.tar.gz: 0206f67ee329e747bad7da7cd3082c6be17811e19ef97ec67843d61bf50922de
5
+ SHA512:
6
+ metadata.gz: 1704c398b0c6cdfa415a08f0662574eab25acb72b28ac9d8e8b83658750793111aeb9f24248ba22641ebc566500d6fbef2e63b250a895f43d6c7eb451c877d07
7
+ data.tar.gz: 9eab6f79cb46672c4df6dfc26507e91518616ac67ae7119bd07bfe7a177e7488f97918c09280eafcf8192953014d264bbc5beacc9c6bd5ced23fbb2e885f1e26
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT
2
+
3
+ Copyright (c) 2026 Shakha Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Shakha
2
+
3
+ Minimal auth broker for Google OAuth with PKCE and pairwise subjects.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "shakha"
11
+ ```
12
+
13
+ Run the installer:
14
+
15
+ ```bash
16
+ rails generate shakha:install
17
+ ```
18
+
19
+ Set environment variables:
20
+
21
+ ```bash
22
+ export SHAKHA_APP_ORIGIN="https://yourapp.com"
23
+ export SHAKHA_SERVICE_SECRET="your-secret-key"
24
+ export GOOGLE_CLIENT_ID="your-google-client-id"
25
+ export GOOGLE_CLIENT_SECRET="your-google-client-secret"
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ See `config/initializers/shakha.rb` for all options.
31
+
32
+ ## Usage
33
+
34
+ ### Sign In
35
+
36
+ Redirect users to sign in:
37
+
38
+ ```erb
39
+ <%= link_to "Sign in with Google", shakha.new_auth_path %>
40
+ ```
41
+
42
+ ### Current User
43
+
44
+ In controllers:
45
+
46
+ ```ruby
47
+ class ApplicationController < ActionController::Base
48
+ include Shakha::ControllerHelpers
49
+ end
50
+ ```
51
+
52
+ ```ruby
53
+ current_user # Shakha::User or nil
54
+ current_session # Shakha::Session or nil
55
+ signed_in? # boolean
56
+ authenticate! # redirect to login if not signed in
57
+ ```
58
+
59
+ ### Protect Routes
60
+
61
+ ```ruby
62
+ class PostsController < ApplicationController
63
+ before_action :authenticate!
64
+ end
65
+ ```
66
+
67
+ ### JWT Verification (API Mode)
68
+
69
+ ```ruby
70
+ payload = Shakha.verify_token(id_token)
71
+ user_id = payload[:sub]
72
+ ```
73
+
74
+ ## Architecture
75
+
76
+ - **PKCE** — S256 code challenges on every flow
77
+ - **Pairwise subjects** — domain-scoped user identifiers
78
+ - **ES256 JWTs** — signed with JWKS endpoint
79
+ - **Database sessions** — DHH-style, no Redis
80
+ - **Turbo native** — zero JS needed
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,193 @@
1
+ @layer shakha.base {
2
+ :root {
3
+ --shakha-bg: oklch(98% 0.002 250);
4
+ --shakha-surface: oklch(100% 0 0);
5
+ --shakha-border: oklch(87% 0.01 250);
6
+ --shakha-text: oklch(20% 0.03 250);
7
+ --shakha-text-muted: oklch(50% 0.02 250);
8
+ --shakha-primary: oklch(55% 0.2 250);
9
+ --shakha-primary-hover: oklch(50% 0.22 250);
10
+ --shakha-error: oklch(60% 0.2 25);
11
+ --shakha-radius: 8px;
12
+ --shakha-shadow: 0 1px 3px oklch(0% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.1);
13
+ }
14
+
15
+ * {
16
+ margin: 0;
17
+ padding: 0;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
23
+ background: var(--shakha-bg);
24
+ color: var(--shakha-text);
25
+ line-height: 1.5;
26
+ -webkit-font-smoothing: antialiased;
27
+ }
28
+
29
+ a {
30
+ color: var(--shakha-primary);
31
+ text-decoration: none;
32
+ }
33
+
34
+ a:hover {
35
+ text-decoration: underline;
36
+ }
37
+ }
38
+
39
+ @layer shakha.layout {
40
+ .shakha-container {
41
+ min-height: 100vh;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ padding: 1.5rem;
46
+ }
47
+
48
+ .shakha-card {
49
+ width: 100%;
50
+ max-width: 400px;
51
+ background: var(--shakha-surface);
52
+ border: 1px solid var(--shakha-border);
53
+ border-radius: calc(var(--shakha-radius) + 4px);
54
+ box-shadow: var(--shakha-shadow);
55
+ overflow: hidden;
56
+ }
57
+
58
+ .shakha-card-center {
59
+ display: flex;
60
+ flex-direction: column;
61
+ align-items: center;
62
+ justify-content: center;
63
+ min-height: 200px;
64
+ padding: 2rem;
65
+ }
66
+
67
+ .shakha-card-error {
68
+ text-align: center;
69
+ padding: 2rem;
70
+ }
71
+
72
+ .shakha-header {
73
+ padding: 1.5rem 1.5rem 0;
74
+ text-align: center;
75
+ }
76
+
77
+ .shakha-header h1 {
78
+ font-size: 1.25rem;
79
+ font-weight: 600;
80
+ color: var(--shakha-text);
81
+ }
82
+
83
+ .shakha-body {
84
+ padding: 1.5rem;
85
+ }
86
+ }
87
+
88
+ @layer shakha.components {
89
+ .shakha-button {
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ gap: 0.75rem;
94
+ width: 100%;
95
+ padding: 0.75rem 1rem;
96
+ border-radius: var(--shakha-radius);
97
+ font-size: 0.9375rem;
98
+ font-weight: 500;
99
+ text-decoration: none;
100
+ cursor: pointer;
101
+ transition: background 0.15s ease, border-color 0.15s ease;
102
+ }
103
+
104
+ .shakha-button-google {
105
+ background: var(--shakha-surface);
106
+ border: 1px solid var(--shakha-border);
107
+ color: var(--shakha-text);
108
+ }
109
+
110
+ .shakha-button-google:hover {
111
+ background: var(--shakha-bg);
112
+ text-decoration: none;
113
+ }
114
+
115
+ .shakha-button-primary {
116
+ background: var(--shakha-primary);
117
+ border: 1px solid var(--shakha-primary);
118
+ color: white;
119
+ }
120
+
121
+ .shakha-button-primary:hover {
122
+ background: var(--shakha-primary-hover);
123
+ text-decoration: none;
124
+ }
125
+
126
+ .shakha-google-icon {
127
+ width: 18px;
128
+ height: 18px;
129
+ flex-shrink: 0;
130
+ }
131
+
132
+ .shakha-privacy {
133
+ margin-top: 1rem;
134
+ font-size: 0.75rem;
135
+ color: var(--shakha-text-muted);
136
+ text-align: center;
137
+ }
138
+
139
+ .shakha-privacy a {
140
+ color: var(--shakha-text-muted);
141
+ text-decoration: underline;
142
+ }
143
+ }
144
+
145
+ @layer shakha.states {
146
+ .shakha-loading {
147
+ display: flex;
148
+ flex-direction: column;
149
+ align-items: center;
150
+ gap: 1rem;
151
+ }
152
+
153
+ .shakha-loading p {
154
+ color: var(--shakha-text-muted);
155
+ font-size: 0.875rem;
156
+ }
157
+
158
+ .shakha-spinner {
159
+ width: 32px;
160
+ height: 32px;
161
+ border: 3px solid var(--shakha-border);
162
+ border-top-color: var(--shakha-primary);
163
+ border-radius: 50%;
164
+ animation: shakha-spin 0.8s linear infinite;
165
+ }
166
+
167
+ @keyframes shakha-spin {
168
+ to {
169
+ transform: rotate(360deg);
170
+ }
171
+ }
172
+
173
+ .shakha-error-icon {
174
+ margin-bottom: 1rem;
175
+ }
176
+
177
+ .shakha-error-icon svg {
178
+ width: 48px;
179
+ height: 48px;
180
+ color: var(--shakha-error);
181
+ }
182
+
183
+ .shakha-card-error h1 {
184
+ font-size: 1.25rem;
185
+ font-weight: 600;
186
+ margin-bottom: 0.5rem;
187
+ }
188
+
189
+ .shakha-error-message {
190
+ color: var(--shakha-text-muted);
191
+ margin-bottom: 1.5rem;
192
+ }
193
+ }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class ApplicationController < ActionController::Base
5
+ include ErrorHandler
6
+ include ControllerHelpers
7
+
8
+ protect_from_forgery with: :exception
9
+
10
+ layout -> { false if request.format == :json }
11
+
12
+ rescue_from ActiveSupport::ActionController::InvalidAuthenticityToken, with: :invalid_csrf_token
13
+
14
+ private
15
+
16
+ def invalid_csrf_token(exception)
17
+ render json: { error: "Invalid CSRF token" }, status: :unprocessable_entity
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Shakha
7
+ class AuthController < ApplicationController
8
+ include PKCEMixin
9
+
10
+ skip_before_action :verify_authenticity_token, only: [:callback, :token]
11
+
12
+ def new
13
+ @client = find_or_create_client
14
+ @return_to = params[:return_to] || "/"
15
+ end
16
+
17
+ def authorize
18
+ pkce = create_pkce_bundle
19
+ @client = find_or_create_client
20
+
21
+ google_auth_url = build_google_auth_url(pkce)
22
+
23
+ redirect_to google_auth_url
24
+ end
25
+
26
+ def callback
27
+ verifier = verify_pkce!(params[:code])
28
+
29
+ exchange_code_for_tokens(params[:code], verifier)
30
+ rescue PKCEError, GoogleOAuthError => e
31
+ redirect_to shakha.error_path(message: e.message)
32
+ end
33
+
34
+ def token
35
+ code = params[:code]
36
+ verifier = params[:code_verifier]
37
+
38
+ raise PKCEError, "Missing code" unless code
39
+ raise PKCEError, "Missing code_verifier" unless verifier
40
+
41
+ id_token = exchange_code_for_id_token(code, verifier)
42
+
43
+ render json: {
44
+ id_token: id_token,
45
+ pairwise_sub: id_token_payload(id_token)[:sub],
46
+ expires_in: 24.hours.to_i
47
+ }
48
+ rescue PKCEError, JWTError, GoogleOAuthError => e
49
+ render json: { error: e.message }, status: :unauthorized
50
+ end
51
+
52
+ def error
53
+ @message = params[:message] || "Authentication failed"
54
+ end
55
+
56
+ private
57
+
58
+ def find_or_create_client
59
+ origin = URI.parse(request.origin).origin
60
+
61
+ Shakha::Client.find_or_create_by!(origin: origin) do |client|
62
+ client.name = URI.parse(request.origin).host
63
+ end
64
+ end
65
+
66
+ def build_google_auth_url(pkce)
67
+ client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
68
+ redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
69
+
70
+ scopes = ["openid", "email", "profile"].join(" ")
71
+ scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
72
+
73
+ params = {
74
+ client_id: client_id,
75
+ redirect_uri: redirect_uri,
76
+ response_type: "code",
77
+ scope: scopes,
78
+ code_challenge: pkce[:challenge],
79
+ code_challenge_method: "S256",
80
+ state: pkce[:state],
81
+ access_type: "offline",
82
+ prompt: "consent"
83
+ }
84
+
85
+ URI.parse("https://accounts.google.com/o/oauth2/v2/auth").tap do |uri|
86
+ uri.query = URI.encode_www_form(params)
87
+ end.to_s
88
+ end
89
+
90
+ def exchange_code_for_tokens(code, verifier)
91
+ client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
92
+ client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
93
+ redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
94
+
95
+ response = http_post(
96
+ "https://oauth2.googleapis.com/token",
97
+ {
98
+ code: code,
99
+ client_id: client_id,
100
+ client_secret: client_secret,
101
+ redirect_uri: redirect_uri,
102
+ grant_type: "authorization_code",
103
+ code_verifier: verifier
104
+ }
105
+ )
106
+
107
+ tokens = JSON.parse(response.body)
108
+ id_token = tokens["id_token"]
109
+ access_token = tokens["access_token"]
110
+
111
+ raise GoogleOAuthError, "No id_token received" unless id_token
112
+
113
+ payload = decode_id_token(id_token)
114
+ google_sub = payload["sub"]
115
+ pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
116
+
117
+ client = find_or_create_client
118
+ user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub)
119
+
120
+ if params[:request_pii] && payload["email"]
121
+ user.assign_attributes(
122
+ email: payload["email"],
123
+ name: payload["name"],
124
+ picture: payload["picture"]
125
+ )
126
+ end
127
+ user.save!
128
+
129
+ session_record = Shakha::Session.create!(
130
+ user: user,
131
+ client: client,
132
+ jti: SecureRandom.uuid
133
+ )
134
+
135
+ cookies.encrypted[:shakha_session_token] = {
136
+ value: session_record.token,
137
+ httponly: true,
138
+ secure: Rails.env.production?,
139
+ same_site: :lax,
140
+ expires: Shakha.config.session_lifetime.from_now
141
+ }
142
+
143
+ return_to = pkce_state&.dig(:return_to) || "/"
144
+
145
+ redirect_to return_to
146
+ end
147
+
148
+ def exchange_code_for_id_token(code, verifier)
149
+ client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
150
+ client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
151
+ redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
152
+
153
+ response = http_post(
154
+ "https://oauth2.googleapis.com/token",
155
+ {
156
+ code: code,
157
+ client_id: client_id,
158
+ client_secret: client_secret,
159
+ redirect_uri: redirect_uri,
160
+ grant_type: "authorization_code",
161
+ code_verifier: verifier
162
+ }
163
+ )
164
+
165
+ tokens = JSON.parse(response.body)
166
+ tokens["id_token"] || raise(GoogleOAuthError, "No id_token in response")
167
+ end
168
+
169
+ def id_token_payload(id_token)
170
+ JWT.decode(id_token, nil, false)[0]
171
+ end
172
+
173
+ def decode_id_token(id_token)
174
+ JWT.decode(id_token, nil, false)[0]
175
+ end
176
+
177
+ def http_post(url, body)
178
+ uri = URI.parse(url)
179
+ http = Net::HTTP.new(uri.host, uri.port)
180
+ http.use_ssl = uri.scheme == "https"
181
+
182
+ request = Net::HTTP::Post.new(uri.request_uri)
183
+ request["Content-Type"] = "application/x-www-form-urlencoded"
184
+ request.body = URI.encode_www_form(body)
185
+
186
+ http.request(request)
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class SessionController < ApplicationController
5
+ skip_before_action :verify_authenticity_token, only: [:check]
6
+
7
+ def show
8
+ render json: {
9
+ user_id: current_user&.pairwise_sub,
10
+ email: current_user&.email,
11
+ name: current_user&.name,
12
+ expires_at: current_session&.expires_at&.iso8601
13
+ }
14
+ end
15
+
16
+ def check
17
+ if signed_in?
18
+ render json: { status: "active" }
19
+ else
20
+ render json: {
21
+ status: "login_required",
22
+ reason: "no_session"
23
+ }, status: :unauthorized
24
+ end
25
+ end
26
+
27
+ def destroy
28
+ current_session&.destroy
29
+ cookies.delete(:shakha_session_token)
30
+ render json: { status: "signed_out" }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class Client < ::ApplicationRecord
5
+ self.table_name = "shakha_clients"
6
+
7
+ has_many :sessions, class_name: "Shakha::Session", dependent: :restrict_with_error
8
+ has_many :users, class_name: "Shakha::User", dependent: :nullify
9
+
10
+ validates :origin, presence: true, uniqueness: true
11
+
12
+ def client_id
13
+ "origin:#{origin}"
14
+ end
15
+
16
+ def self.find_by_origin!(origin)
17
+ find_by!(origin: URI.parse(origin).origin)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class Session < ::ApplicationRecord
5
+ self.table_name = "shakha_sessions"
6
+
7
+ belongs_to :user, class_name: "Shakha::User", optional: true
8
+ belongs_to :client, class_name: "Shakha::Client"
9
+
10
+ before_create :generate_token
11
+ before_create :generate_jti
12
+
13
+ scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
14
+
15
+ def expired?
16
+ created_at < Shakha.config.session_lifetime.ago
17
+ end
18
+
19
+ def expires_at
20
+ created_at + Shakha.config.session_lifetime
21
+ end
22
+
23
+ private
24
+
25
+ def generate_token
26
+ self.token ||= SecureRandom.urlsafe_base64(32)
27
+ end
28
+
29
+ def generate_jti
30
+ self.jti ||= SecureRandom.uuid
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shakha
4
+ class User < ::ApplicationRecord
5
+ self.table_name = "shakha_users"
6
+
7
+ has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
8
+
9
+ validates :pairwise_sub, presence: true, uniqueness: true
10
+ validates :email, uniqueness: true, allow_blank: true
11
+
12
+ def can_access?(resource)
13
+ true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Signing In..." %>
2
+
3
+ <div class="shakha-container">
4
+ <div class="shakha-card shakha-card-center">
5
+ <div class="shakha-loading">
6
+ <div class="shakha-spinner"></div>
7
+ <p>Signing you in...</p>
8
+ </div>
9
+ </div>
10
+ </div>
11
+
12
+ <meta http-equiv="refresh" content="2;url=<%= @return_to || "/" %>">
@@ -0,0 +1,29 @@
1
+ <% content_for :title, "Sign In" %>
2
+
3
+ <div class="shakha-container">
4
+ <div class="shakha-card">
5
+ <div class="shakha-header">
6
+ <h1>Sign in to <%= @client&.name || "your app" %></h1>
7
+ </div>
8
+
9
+ <div class="shakha-body">
10
+ <%= link_to shakha.authorize_path(request_pii: 1),
11
+ class: "shakha-button shakha-button-google",
12
+ data: { turbo: false } do %>
13
+ <svg class="shakha-google-icon" viewBox="0 0 18 18" aria-hidden="true">
14
+ <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"/>
15
+ <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"/>
16
+ <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"/>
17
+ <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"/>
18
+ </svg>
19
+ Continue with Google
20
+ <% end %>
21
+
22
+ <p class="shakha-privacy">
23
+ By signing in, you agree to our
24
+ <%= link_to "Terms of Service", "#" %> and
25
+ <%= link_to "Privacy Policy", "#" %>.
26
+ </p>
27
+ </div>
28
+ </div>
29
+ </div>