Class: GoogleOauthValidator

Inherits:
Object
  • Object
show all
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

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