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 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSB::Auth::Google::Engine.routes.draw do
4
+ get '/', to: 'oauth#redirect', as: :google_oauth_redirect
5
+ get '/callback', to: 'oauth#callback', as: :google_oauth_callback
6
+ end
@@ -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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ module Google
6
+ class Configuration
7
+ attr_accessor :client_id, :client_secret
8
+
9
+ def initialize
10
+ @client_id = nil
11
+ @client_secret = nil
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ module Google
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rsb/auth/google'
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: []