rsb-auth-google 0.1.1
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 +7 -0
- data/Rakefile +25 -0
- data/app/controllers/rsb/auth/google/oauth_controller.rb +208 -0
- data/app/models/rsb/auth/google/credential.rb +35 -0
- data/app/services/rsb/auth/google/callback_service.rb +216 -0
- data/app/services/rsb/auth/google/oauth_service.rb +131 -0
- data/app/views/rsb/auth/credentials/_icon_google.html.erb +6 -0
- data/app/views/rsb/auth/google/credentials/_google.html.erb +21 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260305000001_add_provider_uid_to_rsb_auth_credentials.rb +18 -0
- data/lib/generators/rsb/auth/google/install/install_generator.rb +71 -0
- data/lib/generators/rsb/auth/google/install/templates/initializer.rb +22 -0
- data/lib/rsb/auth/google/configuration.rb +16 -0
- data/lib/rsb/auth/google/engine.rb +67 -0
- data/lib/rsb/auth/google/jwks_loader.rb +91 -0
- data/lib/rsb/auth/google/settings_schema.rb +35 -0
- data/lib/rsb/auth/google/test_helper.rb +191 -0
- data/lib/rsb/auth/google/version.rb +9 -0
- data/lib/rsb/auth/google.rb +32 -0
- data/lib/rsb-auth-google.rb +3 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d5eaff5c93953718ff6ae83895766a9176dee666d784319f2a885f697d0b7013
|
|
4
|
+
data.tar.gz: ddae43ce7cea9750da866de8cb82cdfeb53926d1dff741b9efd4662df44b560a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fb2815abd4cbafb695b8cb5388d0fe3a86f943a42cd0c0ad100a636aaf9aafc0c6eaa1930c7470ada93265d51f5b2b45f2c92449c837e14304dc12fd06d2aaaa
|
|
7
|
+
data.tar.gz: df33e7ba3d7202fe718f07167f14c06a643c77a703213e155d0c4d218f68dd164c7c5c2a43e9cfdde26ba0da89eb5609b4ec66c3f9a96afef73cb5a75b51231d
|
data/Rakefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rake/testtask'
|
|
6
|
+
|
|
7
|
+
task :prepare_test_db do
|
|
8
|
+
ENV['RAILS_ENV'] = 'test'
|
|
9
|
+
db = File.expand_path('test/dummy/db/test.sqlite3', __dir__)
|
|
10
|
+
FileUtils.rm_f(db)
|
|
11
|
+
require_relative 'test/dummy/config/environment'
|
|
12
|
+
ActiveRecord::Migration.verbose = false
|
|
13
|
+
ActiveRecord::MigrationContext.new(Rails.application.paths['db/migrate'].to_a).migrate
|
|
14
|
+
schema = File.expand_path('test/dummy/db/schema.rb', __dir__)
|
|
15
|
+
File.open(schema, 'w') { |f| ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, f) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Rake::TestTask.new(:test) do |t|
|
|
19
|
+
t.libs << 'test'
|
|
20
|
+
t.pattern = 'test/**/*_test.rb'
|
|
21
|
+
t.verbose = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
task test: :prepare_test_db
|
|
25
|
+
task default: :test
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
# Handles Google OAuth redirect and callback.
|
|
7
|
+
# Mounted at /auth/oauth/google (configured in routes).
|
|
8
|
+
#
|
|
9
|
+
# Actions:
|
|
10
|
+
# GET / (redirect) -- Generates state/nonce, redirects to Google consent screen
|
|
11
|
+
# GET /callback -- Validates state, exchanges code, creates/links credential
|
|
12
|
+
class OauthController < RSB::Auth::ApplicationController
|
|
13
|
+
GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
14
|
+
|
|
15
|
+
include RSB::Auth::RateLimitable
|
|
16
|
+
|
|
17
|
+
skip_before_action :verify_authenticity_token, only: :callback
|
|
18
|
+
before_action -> { throttle!(key: 'google_oauth_redirect', limit: 30, period: 60) }, only: :redirect
|
|
19
|
+
before_action -> { throttle!(key: 'google_oauth_callback', limit: 20, period: 60) }, only: :callback
|
|
20
|
+
|
|
21
|
+
# Initiates the Google OAuth flow.
|
|
22
|
+
# Generates CSRF state + nonce, stores in session, and redirects
|
|
23
|
+
# the user to Google's OAuth consent screen.
|
|
24
|
+
#
|
|
25
|
+
# @route GET /auth/oauth/google
|
|
26
|
+
def redirect
|
|
27
|
+
unless google_enabled?
|
|
28
|
+
redirect_to main_app_login_path, alert: 'This sign-in method is not available.'
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
unless google_configured?
|
|
33
|
+
Rails.logger.error { "#{LOG_TAG} Google OAuth not configured: missing client_id or client_secret" }
|
|
34
|
+
redirect_to main_app_login_path, alert: 'Google authentication is not configured.'
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
mode = params[:mode].presence || 'login'
|
|
39
|
+
if mode == 'link' && !current_identity
|
|
40
|
+
redirect_to rsb_auth.new_session_path
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
45
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
46
|
+
session[:google_oauth_state] = state
|
|
47
|
+
session[:google_oauth_nonce] = nonce
|
|
48
|
+
session[:google_oauth_mode] = mode
|
|
49
|
+
|
|
50
|
+
Rails.logger.info { "#{LOG_TAG} Initiating Google OAuth for mode=#{mode}" }
|
|
51
|
+
|
|
52
|
+
auth_params = {
|
|
53
|
+
client_id: RSB::Settings.get('auth.credentials.google.client_id'),
|
|
54
|
+
redirect_uri: google_callback_url,
|
|
55
|
+
response_type: 'code',
|
|
56
|
+
scope: 'openid email',
|
|
57
|
+
state: state,
|
|
58
|
+
nonce: nonce
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
login_hint = sanitize_login_hint(params[:login_hint])
|
|
62
|
+
auth_params[:login_hint] = login_hint if login_hint.present?
|
|
63
|
+
|
|
64
|
+
redirect_to "#{GOOGLE_AUTH_URL}?#{auth_params.to_query}", allow_other_host: true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Processes the Google OAuth callback.
|
|
68
|
+
# Validates state, exchanges authorization code for tokens,
|
|
69
|
+
# verifies id_token, and delegates to CallbackService.
|
|
70
|
+
#
|
|
71
|
+
# @route GET /auth/oauth/google/callback
|
|
72
|
+
def callback
|
|
73
|
+
unless google_enabled?
|
|
74
|
+
clear_oauth_session
|
|
75
|
+
redirect_to main_app_login_path, alert: 'This sign-in method is not available.'
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if params[:error] == 'access_denied'
|
|
80
|
+
clear_oauth_session
|
|
81
|
+
redirect_to main_app_login_path, alert: 'Google authentication was cancelled.'
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Rails.logger.info { "#{LOG_TAG} Google OAuth callback received" }
|
|
86
|
+
|
|
87
|
+
unless valid_state?
|
|
88
|
+
Rails.logger.warn { "#{LOG_TAG} Google OAuth state mismatch (possible CSRF)" }
|
|
89
|
+
clear_oauth_session
|
|
90
|
+
redirect_to main_app_login_path, alert: 'Authentication failed. Please try again.'
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
oauth_result = OauthService.new.exchange_and_verify(
|
|
95
|
+
code: params[:code],
|
|
96
|
+
redirect_uri: google_callback_url,
|
|
97
|
+
nonce: session[:google_oauth_nonce]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
unless oauth_result.success?
|
|
101
|
+
clear_oauth_session
|
|
102
|
+
redirect_to main_app_login_path, alert: 'Authentication failed. Please try again.'
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
mode = session[:google_oauth_mode] || 'login'
|
|
107
|
+
callback_result = CallbackService.new.call(
|
|
108
|
+
email: oauth_result.email,
|
|
109
|
+
google_uid: oauth_result.google_uid,
|
|
110
|
+
mode: mode,
|
|
111
|
+
current_identity: current_identity
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
clear_oauth_session
|
|
115
|
+
|
|
116
|
+
if callback_result.success?
|
|
117
|
+
handle_success(callback_result, mode)
|
|
118
|
+
else
|
|
119
|
+
handle_failure(callback_result, mode)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def google_enabled?
|
|
126
|
+
RSB::Auth.credentials.enabled?(:google)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def google_configured?
|
|
132
|
+
client_id = RSB::Settings.get('auth.credentials.google.client_id')
|
|
133
|
+
client_secret = RSB::Settings.get('auth.credentials.google.client_secret')
|
|
134
|
+
client_id.present? && client_secret.present?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def valid_state?
|
|
138
|
+
params[:state].present? &&
|
|
139
|
+
session[:google_oauth_state].present? &&
|
|
140
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
141
|
+
params[:state].to_s,
|
|
142
|
+
session[:google_oauth_state].to_s
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_success(result, _mode)
|
|
147
|
+
case result.action
|
|
148
|
+
when :linked, :already_linked
|
|
149
|
+
flash_msg = result.action == :linked ? 'Google account linked successfully.' : 'Google account is already linked.'
|
|
150
|
+
redirect_to rsb_auth.account_path, notice: flash_msg
|
|
151
|
+
else # :logged_in, :registered
|
|
152
|
+
create_auth_session(result.identity)
|
|
153
|
+
if result.identity.complete?
|
|
154
|
+
redirect_to main_app.root_path, notice: 'Signed in.'
|
|
155
|
+
else
|
|
156
|
+
redirect_to rsb_auth.account_path, alert: 'Please complete your profile.'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_failure(result, mode)
|
|
162
|
+
redirect_path = mode == 'link' ? rsb_auth.account_path : main_app_login_path
|
|
163
|
+
redirect_to redirect_path, alert: result.error
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def create_auth_session(identity)
|
|
167
|
+
session_record = RSB::Auth::SessionService.new.create(
|
|
168
|
+
identity: identity,
|
|
169
|
+
ip_address: request.remote_ip,
|
|
170
|
+
user_agent: request.user_agent
|
|
171
|
+
)
|
|
172
|
+
cookies.signed[:rsb_session_token] = {
|
|
173
|
+
value: session_record.token,
|
|
174
|
+
httponly: true,
|
|
175
|
+
same_site: :lax,
|
|
176
|
+
secure: Rails.env.production?
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clear_oauth_session
|
|
181
|
+
session.delete(:google_oauth_state)
|
|
182
|
+
session.delete(:google_oauth_nonce)
|
|
183
|
+
session.delete(:google_oauth_mode)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def sanitize_login_hint(hint)
|
|
187
|
+
return nil if hint.blank?
|
|
188
|
+
|
|
189
|
+
hint.strip.truncate(255, omission: '')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def google_callback_url
|
|
193
|
+
url_for(action: :callback, only_path: false)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def main_app_login_path
|
|
197
|
+
rsb_auth.new_session_path
|
|
198
|
+
rescue StandardError
|
|
199
|
+
'/auth/session/new'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def rsb_auth
|
|
203
|
+
RSB::Auth::Engine.routes.url_helpers
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
class Credential < RSB::Auth::Credential
|
|
7
|
+
PLACEHOLDER_DIGEST = '$2a$04$placeholder.digest.for.google.oauth.credentials.only'
|
|
8
|
+
|
|
9
|
+
before_validation :set_placeholder_digest, if: -> { password_digest.blank? }
|
|
10
|
+
|
|
11
|
+
validates :identifier, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
12
|
+
validates :provider_uid, presence: true
|
|
13
|
+
validates :provider_uid, uniqueness: { scope: :type, conditions: -> { where(revoked_at: nil) } }
|
|
14
|
+
|
|
15
|
+
def authenticate(_password)
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def google_email
|
|
20
|
+
identifier
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def password_required?
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_placeholder_digest
|
|
30
|
+
self.password_digest = PLACEHOLDER_DIGEST
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
# Processes verified Google claims into a credential/identity.
|
|
7
|
+
# Handles three modes: login (find or register), signup (register),
|
|
8
|
+
# and link (add to existing identity).
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# service = RSB::Auth::Google::CallbackService.new
|
|
12
|
+
# result = service.call(
|
|
13
|
+
# email: 'user@gmail.com',
|
|
14
|
+
# google_uid: '118234567890',
|
|
15
|
+
# mode: 'login',
|
|
16
|
+
# current_identity: nil
|
|
17
|
+
# )
|
|
18
|
+
class CallbackService
|
|
19
|
+
Result = Data.define(:success?, :identity, :credential, :error, :action)
|
|
20
|
+
|
|
21
|
+
# Processes the Google OAuth callback.
|
|
22
|
+
#
|
|
23
|
+
# @param email [String] verified Google email
|
|
24
|
+
# @param google_uid [String] Google user ID (sub claim)
|
|
25
|
+
# @param mode [String] "login", "signup", or "link"
|
|
26
|
+
# @param current_identity [RSB::Auth::Identity, nil] current identity for link mode
|
|
27
|
+
# @return [CallbackService::Result]
|
|
28
|
+
def call(email:, google_uid:, mode:, current_identity: nil)
|
|
29
|
+
case mode.to_s
|
|
30
|
+
when 'link'
|
|
31
|
+
handle_link(email: email, google_uid: google_uid, current_identity: current_identity)
|
|
32
|
+
when 'signup'
|
|
33
|
+
handle_signup(email: email, google_uid: google_uid)
|
|
34
|
+
else # 'login' or default
|
|
35
|
+
handle_login(email: email, google_uid: google_uid)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# --- LOGIN MODE ---
|
|
42
|
+
|
|
43
|
+
def handle_login(email:, google_uid:)
|
|
44
|
+
# Step 1: Look up by provider_uid (primary lookup)
|
|
45
|
+
credential = find_active_credential_by_uid(google_uid)
|
|
46
|
+
if credential
|
|
47
|
+
update_email_if_changed(credential, email)
|
|
48
|
+
return success(identity: credential.identity, credential: credential, action: :logged_in)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Step 2: Look up by email among active Google credentials
|
|
52
|
+
credential = find_active_credential_by_email(email)
|
|
53
|
+
if credential
|
|
54
|
+
return success(identity: credential.identity, credential: credential, action: :logged_in)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Step 3: Auto-merge or auto-register
|
|
58
|
+
handle_no_credential(email: email, google_uid: google_uid)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# --- SIGNUP MODE ---
|
|
62
|
+
|
|
63
|
+
def handle_signup(email:, google_uid:)
|
|
64
|
+
# If existing credential found, treat as login
|
|
65
|
+
credential = find_active_credential_by_uid(google_uid) || find_active_credential_by_email(email)
|
|
66
|
+
if credential
|
|
67
|
+
update_email_if_changed(credential, email) if credential.provider_uid == google_uid
|
|
68
|
+
return success(identity: credential.identity, credential: credential, action: :logged_in)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check registration is allowed
|
|
72
|
+
unless registration_allowed?
|
|
73
|
+
return failure('Registration is currently disabled.')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check for email conflict -> auto-merge or error
|
|
77
|
+
handle_no_credential(email: email, google_uid: google_uid)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --- LINK MODE ---
|
|
81
|
+
|
|
82
|
+
def handle_link(email:, google_uid:, current_identity:)
|
|
83
|
+
unless current_identity
|
|
84
|
+
return failure('Not authenticated. Please log in first.')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if this Google account is already linked
|
|
88
|
+
existing = find_active_credential_by_uid(google_uid)
|
|
89
|
+
if existing
|
|
90
|
+
return success(identity: current_identity, credential: existing, action: :already_linked) if existing.identity_id == current_identity.id
|
|
91
|
+
|
|
92
|
+
return failure('This Google account is already linked to another account.')
|
|
93
|
+
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if email is linked to different identity via Google credential
|
|
97
|
+
email_match = find_active_credential_by_email(email)
|
|
98
|
+
if email_match && email_match.identity_id != current_identity.id
|
|
99
|
+
return failure('This Google account is already linked to another account.')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Create Google credential on current identity
|
|
103
|
+
credential = create_google_credential(
|
|
104
|
+
identity: current_identity,
|
|
105
|
+
email: email,
|
|
106
|
+
google_uid: google_uid
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
Rails.logger.info { "#{LOG_TAG} Linked Google credential to identity id=#{current_identity.id}" }
|
|
110
|
+
success(identity: current_identity, credential: credential, action: :linked)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- SHARED LOGIC ---
|
|
114
|
+
|
|
115
|
+
def handle_no_credential(email:, google_uid:)
|
|
116
|
+
# Look for any active credential (any type) with the same email
|
|
117
|
+
email_credential = RSB::Auth::Credential.active.find_by(identifier: email)
|
|
118
|
+
|
|
119
|
+
if email_credential
|
|
120
|
+
# Email exists on another identity
|
|
121
|
+
return handle_email_conflict(
|
|
122
|
+
email: email,
|
|
123
|
+
google_uid: google_uid,
|
|
124
|
+
existing_identity: email_credential.identity
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# No email match -- register new identity (if allowed)
|
|
129
|
+
register_new_identity(email: email, google_uid: google_uid)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def handle_email_conflict(email:, google_uid:, existing_identity:)
|
|
133
|
+
auto_merge = RSB::Settings.get('auth.credentials.google.auto_merge_by_email')
|
|
134
|
+
|
|
135
|
+
if auto_merge
|
|
136
|
+
# Auto-merge: create Google credential on existing identity
|
|
137
|
+
credential = create_google_credential(
|
|
138
|
+
identity: existing_identity,
|
|
139
|
+
email: email,
|
|
140
|
+
google_uid: google_uid
|
|
141
|
+
)
|
|
142
|
+
Rails.logger.info { "#{LOG_TAG} Auto-merged Google credential to existing identity id=#{existing_identity.id}" }
|
|
143
|
+
return success(identity: existing_identity, credential: credential, action: :logged_in)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# No auto-merge: return error
|
|
147
|
+
Rails.logger.info { "#{LOG_TAG} Google login blocked: email conflict for #{email}, auto_merge disabled" }
|
|
148
|
+
|
|
149
|
+
generic_errors = RSB::Settings.get('auth.generic_error_messages')
|
|
150
|
+
if generic_errors
|
|
151
|
+
failure('Invalid credentials.')
|
|
152
|
+
else
|
|
153
|
+
failure('An account with this email already exists. Please log in with your password and link Google from your account page.')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def register_new_identity(email:, google_uid:)
|
|
158
|
+
unless registration_allowed?
|
|
159
|
+
return failure('Registration is currently disabled.')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
identity = RSB::Auth::Identity.create!(status: :active)
|
|
163
|
+
credential = create_google_credential(
|
|
164
|
+
identity: identity,
|
|
165
|
+
email: email,
|
|
166
|
+
google_uid: google_uid
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
Rails.logger.info { "#{LOG_TAG} Created Google credential for new identity id=#{identity.id}" }
|
|
170
|
+
|
|
171
|
+
success(identity: identity, credential: credential, action: :registered)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def create_google_credential(identity:, email:, google_uid:)
|
|
175
|
+
RSB::Auth::Google::Credential.create!(
|
|
176
|
+
identity: identity,
|
|
177
|
+
identifier: email,
|
|
178
|
+
provider_uid: google_uid,
|
|
179
|
+
verified_at: Time.current
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def find_active_credential_by_uid(google_uid)
|
|
184
|
+
RSB::Auth::Google::Credential.active.find_by(provider_uid: google_uid)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def find_active_credential_by_email(email)
|
|
188
|
+
RSB::Auth::Google::Credential.active.find_by(identifier: email.downcase)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def update_email_if_changed(credential, email)
|
|
192
|
+
return if credential.identifier == email.downcase
|
|
193
|
+
|
|
194
|
+
credential.update!(identifier: email)
|
|
195
|
+
Rails.logger.info { "#{LOG_TAG} Updated Google credential email to #{email} for identity id=#{credential.identity_id}" }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def registration_allowed?
|
|
199
|
+
mode = RSB::Settings.get('auth.registration_mode').to_s
|
|
200
|
+
return false if %w[disabled invite_only].include?(mode)
|
|
201
|
+
|
|
202
|
+
registerable = RSB::Settings.get('auth.credentials.google.registerable')
|
|
203
|
+
ActiveModel::Type::Boolean.new.cast(registerable)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def success(identity:, credential:, action:)
|
|
207
|
+
Result.new(success?: true, identity: identity, credential: credential, error: nil, action: action)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def failure(error)
|
|
211
|
+
Result.new(success?: false, identity: nil, credential: nil, error: error, action: nil)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RSB
|
|
7
|
+
module Auth
|
|
8
|
+
module Google
|
|
9
|
+
# Exchanges a Google authorization code for tokens and verifies the id_token JWT.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# service = RSB::Auth::Google::OauthService.new
|
|
13
|
+
# result = service.exchange_and_verify(
|
|
14
|
+
# code: params[:code],
|
|
15
|
+
# redirect_uri: callback_url,
|
|
16
|
+
# nonce: session[:google_oauth_nonce]
|
|
17
|
+
# )
|
|
18
|
+
# if result.success?
|
|
19
|
+
# # result.email, result.google_uid available
|
|
20
|
+
# end
|
|
21
|
+
class OauthService
|
|
22
|
+
GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token'
|
|
23
|
+
VALID_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'].freeze
|
|
24
|
+
|
|
25
|
+
Result = Data.define(:success?, :email, :google_uid, :error)
|
|
26
|
+
|
|
27
|
+
# Exchanges the authorization code for tokens, then verifies the id_token JWT.
|
|
28
|
+
#
|
|
29
|
+
# @param code [String] the authorization code from Google
|
|
30
|
+
# @param redirect_uri [String] the callback URL (must match what was sent to Google)
|
|
31
|
+
# @param nonce [String] the nonce stored in the session for replay prevention
|
|
32
|
+
# @return [OauthService::Result] result with email and google_uid on success, error on failure
|
|
33
|
+
def exchange_and_verify(code:, redirect_uri:, nonce:)
|
|
34
|
+
# Step 1: Exchange code for tokens
|
|
35
|
+
token_response = exchange_code(code, redirect_uri)
|
|
36
|
+
return failure(:token_exchange_failed) unless token_response
|
|
37
|
+
|
|
38
|
+
id_token = token_response['id_token']
|
|
39
|
+
return failure(:token_exchange_failed) unless id_token.present?
|
|
40
|
+
|
|
41
|
+
# Step 2: Verify the id_token JWT
|
|
42
|
+
claims = verify_id_token(id_token, nonce)
|
|
43
|
+
return failure(:jwt_verification_failed) unless claims
|
|
44
|
+
|
|
45
|
+
email = claims['email']
|
|
46
|
+
google_uid = claims['sub']
|
|
47
|
+
|
|
48
|
+
Rails.logger.info { "#{LOG_TAG} Google token exchange successful for email=#{email}" }
|
|
49
|
+
|
|
50
|
+
Result.new(
|
|
51
|
+
success?: true,
|
|
52
|
+
email: email,
|
|
53
|
+
google_uid: google_uid,
|
|
54
|
+
error: nil
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def exchange_code(code, redirect_uri)
|
|
61
|
+
client_id = RSB::Settings.get('auth.credentials.google.client_id')
|
|
62
|
+
client_secret = RSB::Settings.get('auth.credentials.google.client_secret')
|
|
63
|
+
|
|
64
|
+
uri = URI(GOOGLE_TOKEN_URI)
|
|
65
|
+
response = Net::HTTP.post_form(uri, {
|
|
66
|
+
'code' => code,
|
|
67
|
+
'client_id' => client_id,
|
|
68
|
+
'client_secret' => client_secret,
|
|
69
|
+
'redirect_uri' => redirect_uri,
|
|
70
|
+
'grant_type' => 'authorization_code'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
74
|
+
Rails.logger.error { "#{LOG_TAG} Google token exchange failed: HTTP #{response.code} — #{response.body}" }
|
|
75
|
+
return nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
JSON.parse(response.body)
|
|
79
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError, JSON::ParserError => e
|
|
80
|
+
Rails.logger.error { "#{LOG_TAG} Google token exchange failed: #{e.class}: #{e.message}" }
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verify_id_token(id_token, nonce)
|
|
85
|
+
# Decode header to get kid (key ID)
|
|
86
|
+
header = JWT.decode(id_token, nil, false).last
|
|
87
|
+
kid = header['kid']
|
|
88
|
+
|
|
89
|
+
# Find the matching public key
|
|
90
|
+
key = JwksLoader.find_key(kid)
|
|
91
|
+
unless key
|
|
92
|
+
Rails.logger.warn { "#{LOG_TAG} Google id_token verification failed: unknown kid=#{kid}" }
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Verify signature and decode claims
|
|
97
|
+
client_id = RSB::Settings.get('auth.credentials.google.client_id')
|
|
98
|
+
claims = JWT.decode(
|
|
99
|
+
id_token,
|
|
100
|
+
key,
|
|
101
|
+
true,
|
|
102
|
+
{
|
|
103
|
+
algorithm: 'RS256',
|
|
104
|
+
verify_iss: true,
|
|
105
|
+
iss: VALID_ISSUERS,
|
|
106
|
+
verify_aud: true,
|
|
107
|
+
aud: client_id,
|
|
108
|
+
verify_expiration: true
|
|
109
|
+
}
|
|
110
|
+
).first
|
|
111
|
+
|
|
112
|
+
# Validate nonce (replay prevention)
|
|
113
|
+
if nonce.present? && claims['nonce'] != nonce
|
|
114
|
+
Rails.logger.warn { "#{LOG_TAG} Google id_token verification failed: nonce mismatch" }
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
claims
|
|
119
|
+
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature,
|
|
120
|
+
JWT::InvalidIssuerError, JWT::InvalidAudError => e
|
|
121
|
+
Rails.logger.warn { "#{LOG_TAG} Google id_token verification failed: #{e.class}: #{e.message}" }
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def failure(error)
|
|
126
|
+
Result.new(success?: false, email: nil, google_uid: nil, error: error)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg class="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
|
3
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
4
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
5
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# Google OAuth sign-in button partial.
|
|
2
|
+
Rendered by the credential selector via redirect_url, but also available
|
|
3
|
+
for direct use in custom views.
|
|
4
|
+
|
|
5
|
+
Variables:
|
|
6
|
+
mode: String -- "login", "signup", or "link" (optional, default: "login")
|
|
7
|
+
%>
|
|
8
|
+
<% mode = local_assigns[:mode] || 'login' %>
|
|
9
|
+
<% oauth_url = "/auth/oauth/google?mode=#{mode}" %>
|
|
10
|
+
|
|
11
|
+
<%= link_to oauth_url, class: "inline-flex items-center justify-center w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm bg-white hover:bg-gray-50 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %>
|
|
12
|
+
<svg class="w-5 h-5 mr-3" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
13
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
|
14
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
15
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
16
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
17
|
+
</svg>
|
|
18
|
+
<span class="text-sm font-medium text-gray-700">
|
|
19
|
+
<%= mode == 'signup' ? 'Sign up with Google' : 'Sign in with Google' %>
|
|
20
|
+
</span>
|
|
21
|
+
<% end %>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddProviderUidToRSBAuthCredentials < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
return unless table_exists?(:rsb_auth_credentials)
|
|
6
|
+
|
|
7
|
+
unless column_exists?(:rsb_auth_credentials, :provider_uid)
|
|
8
|
+
add_column :rsb_auth_credentials, :provider_uid, :string
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
return if index_exists?(:rsb_auth_credentials, %i[type provider_uid], name: 'idx_rsb_auth_credentials_type_provider_uid')
|
|
12
|
+
|
|
13
|
+
add_index :rsb_auth_credentials, %i[type provider_uid],
|
|
14
|
+
unique: true,
|
|
15
|
+
where: 'provider_uid IS NOT NULL',
|
|
16
|
+
name: 'idx_rsb_auth_credentials_type_provider_uid'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
namespace 'rsb:auth:google:install'
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
desc 'Install rsb-auth-google: copy migrations (if needed), mount routes, create initializer.'
|
|
11
|
+
|
|
12
|
+
# Copies the provider_uid migration only if the column does not already
|
|
13
|
+
# exist on the rsb_auth_credentials table. This enables multiple OAuth
|
|
14
|
+
# gems to share the same column without migration conflicts.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
17
|
+
def copy_migrations
|
|
18
|
+
if provider_uid_column_exists?
|
|
19
|
+
say 'provider_uid column already exists on rsb_auth_credentials, skipping migration', :yellow
|
|
20
|
+
else
|
|
21
|
+
rake 'rsb_auth_google:install:migrations'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Mounts the rsb-auth-google engine at /auth/oauth/google in the
|
|
26
|
+
# host application's routes.rb.
|
|
27
|
+
#
|
|
28
|
+
# @return [void]
|
|
29
|
+
def mount_routes
|
|
30
|
+
route 'mount RSB::Auth::Google::Engine => "/auth/oauth/google"'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Copies the initializer template with client_id / client_secret
|
|
34
|
+
# placeholders.
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def create_initializer
|
|
38
|
+
template 'initializer.rb', 'config/initializers/rsb_auth_google.rb'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Prints post-installation instructions.
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
def print_post_install
|
|
45
|
+
say ''
|
|
46
|
+
say 'rsb-auth-google installed successfully!', :green
|
|
47
|
+
say ''
|
|
48
|
+
say 'Next steps:'
|
|
49
|
+
say ' 1. rails db:migrate'
|
|
50
|
+
say ' 2. Set your Google OAuth credentials:'
|
|
51
|
+
say ' - In config/initializers/rsb_auth_google.rb, OR'
|
|
52
|
+
say ' - In the admin panel under Settings > Google OAuth, OR'
|
|
53
|
+
say ' - Via ENV: RSB_AUTH_CREDENTIALS_GOOGLE_CLIENT_ID and RSB_AUTH_CREDENTIALS_GOOGLE_CLIENT_SECRET'
|
|
54
|
+
say ' 3. Register your callback URL with Google Cloud Console:'
|
|
55
|
+
say ' https://your-app.com/auth/oauth/google/callback'
|
|
56
|
+
say ' 4. Visit /auth/session/new to see the Google sign-in button'
|
|
57
|
+
say ''
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def provider_uid_column_exists?
|
|
63
|
+
require 'active_record'
|
|
64
|
+
ActiveRecord::Base.connection.column_exists?(:rsb_auth_credentials, :provider_uid)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RSB Auth Google configuration
|
|
4
|
+
# See: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
5
|
+
|
|
6
|
+
RSB::Auth::Google.configure do |config|
|
|
7
|
+
# Google OAuth credentials from Google Cloud Console
|
|
8
|
+
# Get yours at: https://console.cloud.google.com/apis/credentials
|
|
9
|
+
#
|
|
10
|
+
# You can also set these via:
|
|
11
|
+
# - Admin panel: Settings > Google OAuth
|
|
12
|
+
# - ENV: RSB_AUTH_CREDENTIALS_GOOGLE_CLIENT_ID
|
|
13
|
+
# - ENV: RSB_AUTH_CREDENTIALS_GOOGLE_CLIENT_SECRET
|
|
14
|
+
# - RSB::Settings.set('auth.credentials.google.client_id', 'xxx')
|
|
15
|
+
|
|
16
|
+
# config.client_id = 'your-client-id.apps.googleusercontent.com'
|
|
17
|
+
# config.client_secret = 'GOCSPX-your-client-secret'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Optional: configure Google OAuth settings via RSB::Settings
|
|
21
|
+
# RSB::Settings.set('auth.credentials.google.auto_merge_by_email', true)
|
|
22
|
+
# RSB::Settings.set('auth.credentials.google.enabled', true)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RSB::Auth::Google
|
|
8
|
+
|
|
9
|
+
initializer 'rsb_auth_google.register_settings', after: 'rsb_settings.ready' do
|
|
10
|
+
RSB::Settings.registry.register(RSB::Auth::Google::SettingsSchema.build)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'rsb_auth_google.register_credential', after: 'rsb_auth.ready' do
|
|
14
|
+
RSB::Auth.credentials.register(
|
|
15
|
+
RSB::Auth::CredentialDefinition.new(
|
|
16
|
+
key: :google,
|
|
17
|
+
class_name: 'RSB::Auth::Google::Credential',
|
|
18
|
+
authenticatable: true,
|
|
19
|
+
registerable: true,
|
|
20
|
+
label: 'Google',
|
|
21
|
+
icon: 'google',
|
|
22
|
+
form_partial: 'rsb/auth/google/credentials/google',
|
|
23
|
+
redirect_url: '/auth/oauth/google',
|
|
24
|
+
admin_form_partial: nil
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
RSB::Auth::CredentialSettingsRegistrar.register_enabled_settings
|
|
29
|
+
|
|
30
|
+
# Override per-credential defaults for Google via initializer-level config.
|
|
31
|
+
# These are resolved without DB access (resolver chain: DB > initializer > ENV > default).
|
|
32
|
+
RSB::Settings.configure do |config|
|
|
33
|
+
config.set 'auth.credentials.google.verification_required', false
|
|
34
|
+
config.set 'auth.credentials.google.auto_verify_on_signup', true
|
|
35
|
+
config.set 'auth.credentials.google.allow_login_unverified', true
|
|
36
|
+
|
|
37
|
+
if RSB::Auth::Google.configuration.client_id.present?
|
|
38
|
+
config.set 'auth.credentials.google.client_id', RSB::Auth::Google.configuration.client_id
|
|
39
|
+
end
|
|
40
|
+
if RSB::Auth::Google.configuration.client_secret.present?
|
|
41
|
+
config.set 'auth.credentials.google.client_secret', RSB::Auth::Google.configuration.client_secret
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
initializer 'rsb_auth_google.admin_hooks' do
|
|
47
|
+
ActiveSupport.on_load(:rsb_admin) do |_admin_registry|
|
|
48
|
+
# Google credentials are visible through rsb-auth's Identity admin resource.
|
|
49
|
+
# No additional admin resources needed for v1.
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
initializer 'rsb_auth_google.append_migrations' do |app|
|
|
54
|
+
config.paths['db/migrate'].expanded.each do |path|
|
|
55
|
+
app.config.paths['db/migrate'] << path unless app.config.paths['db/migrate'].include?(path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
config.generators do |g|
|
|
60
|
+
g.test_framework :minitest, fixture: false
|
|
61
|
+
g.assets false
|
|
62
|
+
g.helper false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RSB
|
|
7
|
+
module Auth
|
|
8
|
+
module Google
|
|
9
|
+
# Fetches and caches Google's public keys (JWKS) for JWT verification.
|
|
10
|
+
#
|
|
11
|
+
# Keys are cached in Rails.cache with a 1-hour TTL. On kid mismatch
|
|
12
|
+
# (key rotation), the cache is invalidated and keys are re-fetched once.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# key = RSB::Auth::Google::JwksLoader.find_key('kid-123')
|
|
16
|
+
# JWT.decode(token, key, true, algorithm: 'RS256')
|
|
17
|
+
class JwksLoader
|
|
18
|
+
GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs'
|
|
19
|
+
CACHE_KEY = 'rsb:auth:google:jwks'
|
|
20
|
+
CACHE_TTL = 3600 # 1 hour in seconds
|
|
21
|
+
|
|
22
|
+
class FetchError < StandardError; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Fetches Google's JWKS keys. Uses an in-memory cache with 1-hour TTL.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<JWT::JWK>] array of JWK key objects
|
|
28
|
+
# @raise [FetchError] if the HTTP request fails
|
|
29
|
+
def fetch_keys
|
|
30
|
+
if @cached_keys && @cached_at && (Time.current - @cached_at) < CACHE_TTL
|
|
31
|
+
Rails.logger.debug { "#{RSB::Auth::Google::LOG_TAG} Using cached Google JWKS keys" }
|
|
32
|
+
return @cached_keys
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Rails.logger.debug { "#{RSB::Auth::Google::LOG_TAG} Fetching Google JWKS keys" }
|
|
36
|
+
@cached_keys = fetch_keys_from_google
|
|
37
|
+
@cached_at = Time.current
|
|
38
|
+
@cached_keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Finds a JWK key by its key ID (kid).
|
|
42
|
+
# If the kid is not found in the cached keys, invalidates the cache
|
|
43
|
+
# and retries once (handles Google key rotation).
|
|
44
|
+
#
|
|
45
|
+
# @param kid [String] the key ID from the JWT header
|
|
46
|
+
# @return [OpenSSL::PKey::RSA, nil] the RSA public key, or nil if not found
|
|
47
|
+
def find_key(kid)
|
|
48
|
+
keys = fetch_keys
|
|
49
|
+
key = find_in_keyset(keys, kid)
|
|
50
|
+
return key if key
|
|
51
|
+
|
|
52
|
+
# Key rotation: invalidate cache and retry once
|
|
53
|
+
Rails.logger.debug { "#{RSB::Auth::Google::LOG_TAG} kid=#{kid} not found, refreshing JWKS" }
|
|
54
|
+
invalidate_cache!
|
|
55
|
+
keys = fetch_keys
|
|
56
|
+
find_in_keyset(keys, kid)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Invalidates the cached JWKS keys, forcing a fresh fetch.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def invalidate_cache!
|
|
63
|
+
@cached_keys = nil
|
|
64
|
+
@cached_at = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def fetch_keys_from_google
|
|
70
|
+
uri = URI(GOOGLE_JWKS_URI)
|
|
71
|
+
response = Net::HTTP.get_response(uri)
|
|
72
|
+
|
|
73
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
74
|
+
raise FetchError, "Google JWKS fetch failed: HTTP #{response.code}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
jwks_data = JSON.parse(response.body)
|
|
78
|
+
jwks_data['keys'].map { |key_data| JWT::JWK.new(key_data) }
|
|
79
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
|
|
80
|
+
raise FetchError, "Google JWKS fetch failed: #{e.class}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def find_in_keyset(keys, kid)
|
|
84
|
+
jwk = keys.find { |k| k[:kid] == kid }
|
|
85
|
+
jwk&.verify_key
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
class SettingsSchema
|
|
7
|
+
def self.build
|
|
8
|
+
RSB::Settings::Schema.new('auth') do
|
|
9
|
+
setting :'credentials.google.client_id',
|
|
10
|
+
type: :string,
|
|
11
|
+
default: '',
|
|
12
|
+
group: 'Google OAuth',
|
|
13
|
+
label: 'Client ID',
|
|
14
|
+
description: 'Google OAuth client ID from Google Cloud Console'
|
|
15
|
+
|
|
16
|
+
setting :'credentials.google.client_secret',
|
|
17
|
+
type: :string,
|
|
18
|
+
default: '',
|
|
19
|
+
encrypted: true,
|
|
20
|
+
group: 'Google OAuth',
|
|
21
|
+
label: 'Client Secret',
|
|
22
|
+
description: 'Google OAuth client secret (stored encrypted)'
|
|
23
|
+
|
|
24
|
+
setting :'credentials.google.auto_merge_by_email',
|
|
25
|
+
type: :boolean,
|
|
26
|
+
default: false,
|
|
27
|
+
group: 'Google OAuth',
|
|
28
|
+
label: 'Auto-merge by Email',
|
|
29
|
+
description: 'Automatically link Google to existing accounts with matching email'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
module Google
|
|
6
|
+
# Test helper for offline Google OAuth testing.
|
|
7
|
+
# Provides stubs and helpers so host app tests never hit Google's servers.
|
|
8
|
+
#
|
|
9
|
+
# @example Include in test helper
|
|
10
|
+
# class ActiveSupport::TestCase
|
|
11
|
+
# include RSB::Auth::Google::TestHelper
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Use in a test
|
|
15
|
+
# test 'google login works' do
|
|
16
|
+
# register_test_google_credential
|
|
17
|
+
# stub_google_oauth(email: 'user@gmail.com', google_uid: '12345')
|
|
18
|
+
# response = simulate_google_login(email: 'user@gmail.com', google_uid: '12345')
|
|
19
|
+
# assert_response :redirect
|
|
20
|
+
# end
|
|
21
|
+
module TestHelper
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
setup do
|
|
26
|
+
RSB::Auth::Google.reset!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
teardown do
|
|
30
|
+
unstub_google_oauth
|
|
31
|
+
RSB::Auth::Google.reset!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers the Google credential type with test-safe settings.
|
|
36
|
+
# Call this in test setup when you need Google OAuth available.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def register_test_google_credential
|
|
40
|
+
# Register Google settings if not already registered
|
|
41
|
+
register_google_settings_for_test
|
|
42
|
+
|
|
43
|
+
# Register the credential type if not already registered
|
|
44
|
+
unless google_credential_registered?
|
|
45
|
+
RSB::Auth.credentials.register(
|
|
46
|
+
RSB::Auth::CredentialDefinition.new(
|
|
47
|
+
key: :google,
|
|
48
|
+
class_name: 'RSB::Auth::Google::Credential',
|
|
49
|
+
authenticatable: true,
|
|
50
|
+
registerable: true,
|
|
51
|
+
label: 'Google',
|
|
52
|
+
icon: 'google',
|
|
53
|
+
form_partial: 'rsb/auth/google/credentials/google',
|
|
54
|
+
redirect_url: '/auth/oauth/google',
|
|
55
|
+
admin_form_partial: nil
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Set test credentials
|
|
61
|
+
RSB::Settings.set('auth.credentials.google.client_id', 'test-google-client-id')
|
|
62
|
+
RSB::Settings.set('auth.credentials.google.client_secret', 'test-google-client-secret')
|
|
63
|
+
RSB::Settings.set('auth.credentials.google.enabled', true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stubs OauthService to return a successful result with the given claims.
|
|
67
|
+
# No HTTP calls are made to Google -- fully offline.
|
|
68
|
+
#
|
|
69
|
+
# @param email [String] Google email to return
|
|
70
|
+
# @param google_uid [String] Google user ID (sub claim) to return
|
|
71
|
+
# @return [void]
|
|
72
|
+
def stub_google_oauth(email:, google_uid:)
|
|
73
|
+
result = OauthService::Result.new(
|
|
74
|
+
success?: true,
|
|
75
|
+
email: email,
|
|
76
|
+
google_uid: google_uid,
|
|
77
|
+
error: nil
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Replace OauthService#exchange_and_verify with a stub
|
|
81
|
+
@_original_exchange_and_verify = OauthService.instance_method(:exchange_and_verify)
|
|
82
|
+
OauthService.define_method(:exchange_and_verify) { |**_kwargs| result }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Removes the OauthService stub, restoring original behavior.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def unstub_google_oauth
|
|
89
|
+
return unless @_original_exchange_and_verify
|
|
90
|
+
|
|
91
|
+
OauthService.define_method(:exchange_and_verify, @_original_exchange_and_verify)
|
|
92
|
+
@_original_exchange_and_verify = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Performs the full Google OAuth login flow in a test.
|
|
96
|
+
# Visits the redirect endpoint (stores state in session),
|
|
97
|
+
# then hits the callback with the stubbed Google response.
|
|
98
|
+
#
|
|
99
|
+
# @param email [String] Google email
|
|
100
|
+
# @param google_uid [String] Google user ID
|
|
101
|
+
# @param mode [String] "login" or "signup" (default: "login")
|
|
102
|
+
# @return [ActionDispatch::Response] the callback response
|
|
103
|
+
def simulate_google_login(email:, google_uid:, mode: 'login')
|
|
104
|
+
stub_google_oauth(email: email, google_uid: google_uid)
|
|
105
|
+
|
|
106
|
+
# Step 1: GET redirect endpoint (stores state in session)
|
|
107
|
+
get "/auth/oauth/google?mode=#{mode}"
|
|
108
|
+
|
|
109
|
+
# Extract state from session
|
|
110
|
+
state = session[:google_oauth_state]
|
|
111
|
+
|
|
112
|
+
# Step 2: GET callback with code + state
|
|
113
|
+
get "/auth/oauth/google/callback?code=test-auth-code&state=#{state}"
|
|
114
|
+
|
|
115
|
+
response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Performs the Google OAuth link flow for an authenticated identity.
|
|
119
|
+
#
|
|
120
|
+
# @param identity [RSB::Auth::Identity] the identity to link Google to
|
|
121
|
+
# @param email [String] Google email
|
|
122
|
+
# @param google_uid [String] Google user ID
|
|
123
|
+
# @return [ActionDispatch::Response] the callback response
|
|
124
|
+
def simulate_google_link(identity:, email:, google_uid:) # rubocop:disable Lint/UnusedMethodArgument
|
|
125
|
+
stub_google_oauth(email: email, google_uid: google_uid)
|
|
126
|
+
|
|
127
|
+
# Step 1: GET redirect endpoint with mode=link
|
|
128
|
+
get '/auth/oauth/google?mode=link'
|
|
129
|
+
|
|
130
|
+
# Extract state from session
|
|
131
|
+
state = session[:google_oauth_state]
|
|
132
|
+
|
|
133
|
+
# Step 2: GET callback
|
|
134
|
+
get "/auth/oauth/google/callback?code=test-auth-code&state=#{state}"
|
|
135
|
+
|
|
136
|
+
response
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Builds a fake id_token claims hash for unit testing services directly.
|
|
140
|
+
# Does NOT create a signed JWT -- just the claims hash.
|
|
141
|
+
#
|
|
142
|
+
# @param email [String] Google email
|
|
143
|
+
# @param google_uid [String] Google user ID (sub claim)
|
|
144
|
+
# @param nonce [String, nil] optional nonce
|
|
145
|
+
# @return [Hash] id_token claims hash
|
|
146
|
+
def build_google_id_token(email:, google_uid:, nonce: nil)
|
|
147
|
+
client_id = begin
|
|
148
|
+
RSB::Settings.get('auth.credentials.google.client_id')
|
|
149
|
+
rescue StandardError
|
|
150
|
+
'test-google-client-id'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
'iss' => 'https://accounts.google.com',
|
|
155
|
+
'aud' => client_id,
|
|
156
|
+
'sub' => google_uid,
|
|
157
|
+
'email' => email,
|
|
158
|
+
'email_verified' => true,
|
|
159
|
+
'exp' => 1.hour.from_now.to_i,
|
|
160
|
+
'iat' => Time.current.to_i,
|
|
161
|
+
'nonce' => nonce
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def google_credential_registered?
|
|
168
|
+
RSB::Auth.credentials.find(:google).present?
|
|
169
|
+
rescue StandardError
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def register_google_settings_for_test
|
|
174
|
+
schema = RSB::Settings::Schema.new('auth') do
|
|
175
|
+
setting :'credentials.google.client_id', type: :string, default: ''
|
|
176
|
+
setting :'credentials.google.client_secret', type: :string, default: ''
|
|
177
|
+
setting :'credentials.google.auto_merge_by_email', type: :boolean, default: false
|
|
178
|
+
setting :'credentials.google.enabled', type: :boolean, default: true
|
|
179
|
+
setting :'credentials.google.registerable', type: :boolean, default: true
|
|
180
|
+
setting :'credentials.google.verification_required', type: :boolean, default: false
|
|
181
|
+
setting :'credentials.google.auto_verify_on_signup', type: :boolean, default: true
|
|
182
|
+
setting :'credentials.google.allow_login_unverified', type: :boolean, default: true
|
|
183
|
+
end
|
|
184
|
+
RSB::Settings.registry.register(schema)
|
|
185
|
+
rescue RSB::Settings::DuplicateSettingError
|
|
186
|
+
# Already registered by engine or previous test
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'rsb/auth'
|
|
5
|
+
require 'rsb/auth/google/version'
|
|
6
|
+
require 'rsb/auth/google/engine'
|
|
7
|
+
require 'rsb/auth/google/configuration'
|
|
8
|
+
require 'rsb/auth/google/settings_schema'
|
|
9
|
+
require 'rsb/auth/google/jwks_loader'
|
|
10
|
+
require 'rsb/auth/google/test_helper'
|
|
11
|
+
|
|
12
|
+
module RSB
|
|
13
|
+
module Auth
|
|
14
|
+
module Google
|
|
15
|
+
LOG_TAG = '[RSB::Auth::Google]'
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield(configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset!
|
|
27
|
+
@configuration = Configuration.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rsb-auth-google
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aleksandr Marchenko
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: jwt
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.9'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.9'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rsb-auth
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.9.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 0.9.0
|
|
54
|
+
description: Adds Google OAuth as a credential type to RSB's auth system. Registers
|
|
55
|
+
into the credential registry, provides OAuth redirect/callback endpoints, and verifies
|
|
56
|
+
id_tokens via Google JWKS.
|
|
57
|
+
email:
|
|
58
|
+
- alex@marchenko.me
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- Rakefile
|
|
64
|
+
- app/controllers/rsb/auth/google/oauth_controller.rb
|
|
65
|
+
- app/models/rsb/auth/google/credential.rb
|
|
66
|
+
- app/services/rsb/auth/google/callback_service.rb
|
|
67
|
+
- app/services/rsb/auth/google/oauth_service.rb
|
|
68
|
+
- app/views/rsb/auth/credentials/_icon_google.html.erb
|
|
69
|
+
- app/views/rsb/auth/google/credentials/_google.html.erb
|
|
70
|
+
- config/routes.rb
|
|
71
|
+
- db/migrate/20260305000001_add_provider_uid_to_rsb_auth_credentials.rb
|
|
72
|
+
- lib/generators/rsb/auth/google/install/install_generator.rb
|
|
73
|
+
- lib/generators/rsb/auth/google/install/templates/initializer.rb
|
|
74
|
+
- lib/rsb-auth-google.rb
|
|
75
|
+
- lib/rsb/auth/google.rb
|
|
76
|
+
- lib/rsb/auth/google/configuration.rb
|
|
77
|
+
- lib/rsb/auth/google/engine.rb
|
|
78
|
+
- lib/rsb/auth/google/jwks_loader.rb
|
|
79
|
+
- lib/rsb/auth/google/settings_schema.rb
|
|
80
|
+
- lib/rsb/auth/google/test_helper.rb
|
|
81
|
+
- lib/rsb/auth/google/version.rb
|
|
82
|
+
homepage: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
83
|
+
licenses:
|
|
84
|
+
- LGPL-3.0
|
|
85
|
+
metadata:
|
|
86
|
+
source_code_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder
|
|
87
|
+
bug_tracker_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder/issues
|
|
88
|
+
changelog_uri: https://github.com/Rails-SaaS-Builder/rails-saas-builder/blob/master/CHANGELOG.md
|
|
89
|
+
rdoc_options: []
|
|
90
|
+
require_paths:
|
|
91
|
+
- lib
|
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.2'
|
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
requirements: []
|
|
103
|
+
rubygems_version: 3.6.8
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: Google OAuth authentication for Rails SaaS Builder
|
|
106
|
+
test_files: []
|