shakha 0.1.7 → 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
data/lib/shakha/pkce.rb CHANGED
@@ -14,9 +14,7 @@ module Shakha
14
14
 
15
15
  class << self
16
16
  def generate_code_verifier
17
- SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH)
18
- .tr("-_", "+/")
19
- .slice(0, CODE_VERIFIER_LENGTH)
17
+ SecureRandom.urlsafe_base64(CODE_VERIFIER_LENGTH, padding: false)
20
18
  end
21
19
 
22
20
  def generate_code_challenge(verifier)
@@ -33,14 +31,16 @@ module Shakha
33
31
  verifier = PKCEMixin.generate_code_verifier
34
32
  challenge = PKCEMixin.generate_code_challenge(verifier)
35
33
  state = SecureRandom.urlsafe_base64(32)
34
+ nonce = SecureRandom.urlsafe_base64(32)
36
35
  return_to = params[:return_to] || "/"
37
36
 
38
37
  pkce_record = {
39
38
  verifier: verifier,
40
- return_to: return_to
39
+ return_to: return_to,
40
+ nonce: nonce
41
41
  }
42
42
 
43
- cookies[PKCE_COOKIE_NAME] = {
43
+ cookies.encrypted[PKCE_COOKIE_NAME] = {
44
44
  value: pkce_record.merge(state: state).to_json,
45
45
  httponly: true,
46
46
  secure: Rails.env.production?,
@@ -48,11 +48,11 @@ module Shakha
48
48
  expires: Time.now.utc + PKCE_COOKIE_EXPIRY_SECONDS
49
49
  }
50
50
 
51
- { challenge: challenge, state: state }
51
+ { challenge: challenge, state: state, nonce: nonce }
52
52
  end
53
53
 
54
- def verify_pkce!(code_verifier, state_param)
55
- pkce_json = cookies[PKCE_COOKIE_NAME]
54
+ def verify_pkce!(state_param)
55
+ pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
56
56
 
57
57
  raise PKCEError, "No PKCE session found" unless pkce_json
58
58
 
@@ -63,23 +63,17 @@ module Shakha
63
63
  stored_state = pkce_data[:state]
64
64
  stored_verifier = pkce_data[:verifier]
65
65
  stored_return_to = pkce_data[:return_to]
66
+ stored_nonce = pkce_data[:nonce]
66
67
 
67
68
  cookies.delete(PKCE_COOKIE_NAME)
68
69
 
69
- raise PKCEError, "State mismatch" unless stored_state == state_param
70
+ raise PKCEError, "State mismatch" unless ActiveSupport::SecurityUtils.secure_compare(stored_state.to_s, state_param.to_s)
70
71
 
71
- computed = PKCEMixin.generate_code_challenge(code_verifier)
72
- code_challenge = params[:code_challenge]
73
-
74
- if code_challenge.present?
75
- raise PKCEError, "Invalid code verifier" unless computed == code_challenge
76
- end
77
-
78
- { verifier: stored_verifier, return_to: stored_return_to }
72
+ { verifier: stored_verifier, return_to: stored_return_to, nonce: stored_nonce }
79
73
  end
80
74
 
81
75
  def pkce_state
82
- pkce_json = cookies[PKCE_COOKIE_NAME]
76
+ pkce_json = cookies.encrypted[PKCE_COOKIE_NAME]
83
77
  return nil unless pkce_json
84
78
 
85
79
  JSON.parse(pkce_json).with_indifferent_access
@@ -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
@@ -23,16 +23,10 @@ module Shakha
23
23
  return unless Shakha.config.rate_limiting_enabled
24
24
 
25
25
  cache_key = "shakha-rate:#{key}:#{request.remote_ip}"
26
+ count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds)
26
27
 
27
- count = Rails.cache.read(cache_key).to_i + 1
28
-
29
- if count == 1
30
- Rails.cache.write(cache_key, count, expires_in: period.seconds)
31
- elsif count > max
28
+ if count > max
32
29
  render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
33
- return
34
- else
35
- Rails.cache.write(cache_key, count, expires_in: period.seconds)
36
30
  end
37
31
  end
38
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shakha
4
- VERSION = "0.1.7"
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.1.7
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>