Class: GoogleOauthValidator
- Inherits:
-
Object
- Object
- GoogleOauthValidator
- Defined in:
- app/services/google_oauth_validator.rb
Overview
Server-side re-validation of a Google OAuth id_token via Google’s tokeninfo endpoint, mirroring the pattern of re-fetching from the provider’s API rather than trusting omniauth’s parsed payload.
The omniauth-google-oauth2 gem already verifies the JWT signature against Google’s JWKS, so this is defense-in-depth — primarily it ensures:
1. The id_token's audience matches GOOGLE_CLIENT_ID (correct app)
2. email_verified is `true` per Google's own claim (closes the silent
from_omniauth find-by-email link if Google hasn't confirmed the email)
3. The token isn't expired by Google's clock
Usage:
result = GoogleOauthValidator.new(id_token: auth.extra.id_token).validate!
if result.ok?
# safe to use result.email, result.email_verified
else
# logging + reject
end
Lifted into studio-engine (was turf-monster app/services/google_oauth_validator.rb).
Defined Under Namespace
Classes: Result
Constant Summary collapse
- TOKENINFO_URL =
"https://oauth2.googleapis.com/tokeninfo".freeze
- NET_TIMEOUT_SECONDS =
5
Instance Method Summary collapse
-
#initialize(id_token:, expected_aud: ENV["GOOGLE_CLIENT_ID"]) ⇒ GoogleOauthValidator
constructor
A new instance of GoogleOauthValidator.
- #validate! ⇒ Object
Constructor Details
#initialize(id_token:, expected_aud: ENV["GOOGLE_CLIENT_ID"]) ⇒ GoogleOauthValidator
Returns a new instance of GoogleOauthValidator.
35 36 37 38 |
# File 'app/services/google_oauth_validator.rb', line 35 def initialize(id_token:, expected_aud: ENV["GOOGLE_CLIENT_ID"]) @id_token = id_token @expected_aud = expected_aud end |
Instance Method Details
#validate! ⇒ Object
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'app/services/google_oauth_validator.rb', line 40 def validate! # Test-mode affordance: OmniAuth.config.test_mode replaces the real # OAuth flow with mock_auth, which doesn't carry a real id_token. The # gem already verifies the mock signature/state internally; this # validator has nothing real to re-check. # # We bypass on `OmniAuth.config.test_mode` (NOT just Rails.env.test?) # because Playwright can run the e2e suite against a DEV server, and # test_mode is the canonical "we are in a mock OAuth flow" signal. # Production NEVER enables test_mode, so this widens the test surface # without weakening the production guard. test_mode = (defined?(OmniAuth) && OmniAuth.config.respond_to?(:test_mode) && OmniAuth.config.test_mode) return Result.new(ok: true, email: nil, email_verified: true, reason: :test_skip) if @id_token.blank? && (Rails.env.test? || test_mode) return Result.new(ok: false, reason: :missing_id_token) if @id_token.blank? return Result.new(ok: false, reason: :missing_expected_aud) if @expected_aud.blank? response = fetch_tokeninfo return Result.new(ok: false, reason: :tokeninfo_unreachable) unless response if response.code.to_i != 200 return Result.new(ok: false, reason: :tokeninfo_rejected) end body = JSON.parse(response.body) rescue nil return Result.new(ok: false, reason: :tokeninfo_parse_failed) unless body # `email_verified` is a string "true"/"false" in tokeninfo responses. email_verified = body["email_verified"].to_s == "true" unless body["aud"] == @expected_aud return Result.new(ok: false, email: body["email"], email_verified: email_verified, reason: :wrong_audience) end unless email_verified return Result.new(ok: false, email: body["email"], email_verified: false, reason: :email_not_verified) end expiry = body["exp"].to_i if expiry > 0 && expiry < Time.current.to_i return Result.new(ok: false, email: body["email"], email_verified: true, reason: :expired) end Result.new(ok: true, email: body["email"], email_verified: true, reason: nil) end |