Class: StandardId::Providers::Google

Inherits:
Base
  • Object
show all
Defined in:
lib/standard_id/google/providers/google.rb

Constant Summary collapse

AUTH_ENDPOINT =
"https://accounts.google.com/o/oauth2/v2/auth".freeze
TOKEN_ENDPOINT =
"https://oauth2.googleapis.com/token".freeze
USERINFO_ENDPOINT =
"https://www.googleapis.com/oauth2/v2/userinfo".freeze
TOKEN_INFO_ENDPOINT =

Google's documented tokeninfo endpoint, for both ID tokens and access tokens (developers.google.com/identity/sign-in/web/backend-auth, developers.google.com/identity/protocols/oauth2). The legacy www.googleapis.com/oauth2/v3/tokeninfo host is no longer used.

"https://oauth2.googleapis.com/tokeninfo".freeze
VALID_ISSUERS =
["accounts.google.com", "https://accounts.google.com"].freeze
DEFAULT_SCOPE =
"openid email profile".freeze
AUTHORIZATION_PARAM_DEFAULTS =
{
  scope: DEFAULT_SCOPE
}.freeze
LEGACY_ENV =

Pre-0.5.0 install generators wired the fields to these variables. Still read, with a deprecation warning, when the canonical variable (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) is unset and the host never assigns the field.

{
  google_client_id: "GOOGLE_OAUTH_CLIENT_ID",
  google_client_secret: "GOOGLE_OAUTH_CLIENT_SECRET"
}.freeze

Class Method Summary collapse

Class Method Details

.authorization_url(state:, redirect_uri:, **options) ⇒ Object



39
40
41
42
43
44
45
46
# File 'lib/standard_id/google/providers/google.rb', line 39

def authorization_url(state:, redirect_uri:, **options)
  build_authorization_url(
    endpoint: AUTH_ENDPOINT,
    client_id: credentials[:client_id],
    redirect_uri:, state:, options:,
    defaults: AUTHORIZATION_PARAM_DEFAULTS
  )
end

.config_schema ⇒ Object

google_client_id switches the provider on (it is the enabling field). google_client_secret is required while it is set: an enabled provider shows the web sign-in button, and the web flow's code exchange needs the secret — without it the user authenticates with Google and only then does the callback fail. The native id_token and access_token flows verify through Google's tokeninfo endpoint and need only the client ID.

ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as a deprecated fallback.



77
78
79
80
81
82
# File 'lib/standard_id/google/providers/google.rb', line 77

def config_schema
  {
    google_client_id: { type: :string, default: -> { legacy_env(:google_client_id) } },
    google_client_secret: { type: :string, default: -> { legacy_env(:google_client_secret) }, required: true }
  }
end

.default_scope ⇒ Object



84
85
86
# File 'lib/standard_id/google/providers/google.rb', line 84

def default_scope
  DEFAULT_SCOPE
end

.exchange_code_for_user_info(code:, redirect_uri:, nonce: nil) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/standard_id/google/providers/google.rb', line 88

def (code:, redirect_uri:, nonce: nil)
  rescue_to_oauth_error do
    raise StandardId::InvalidRequestError, "Google authorization code is missing" if code.blank?

    creds = credentials
    if creds[:client_secret].blank?
      raise StandardId::InvalidRequestError, "Google OAuth credentials are incomplete: google_client_secret not set"
    end

    token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
      client_id: creds[:client_id],
      client_secret: creds[:client_secret],
      code: code,
      grant_type: "authorization_code",
      redirect_uri: redirect_uri
    }.compact)

    unless token_response.is_a?(Net::HTTPSuccess)
      raise StandardId::InvalidRequestError,
            "Failed to exchange Google authorization code: #{error_reason(token_response)}"
    end

    parsed_token = JSON.parse(token_response.body)
    access_token = parsed_token["access_token"]
    raise StandardId::InvalidRequestError, "Google token response is missing access_token" if access_token.blank?

    # Web flow with a server-generated nonce: check it on the ID token.
    if parsed_token["id_token"].present? && nonce.present?
      verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
    end

    build_response((access_token: access_token), tokens: extract_tokens(parsed_token))
  end
end

.fetch_user_info(access_token:) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/standard_id/google/providers/google.rb', line 159

def (access_token:)
  rescue_to_oauth_error do
    raise StandardId::InvalidRequestError, "Google access token is missing" if access_token.blank?

    verify_token(access_token)
    user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)

    unless user_response.is_a?(Net::HTTPSuccess)
      raise StandardId::InvalidRequestError, "Failed to fetch Google user info: HTTP #{user_response.code}"
    end

    JSON.parse(user_response.body)
  end
end

.get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, nonce: nil, **_options) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/standard_id/google/providers/google.rb', line 48

def (code: nil, id_token: nil, access_token: nil, redirect_uri: nil, nonce: nil, **_options)
  if id_token.present?
    build_response(
      verify_id_token(id_token: id_token, nonce: nonce),
      tokens: { id_token: id_token }
    )
  elsif access_token.present?
    build_response(
      (access_token: access_token),
      tokens: { access_token: access_token }
    )
  elsif code.present?
    (code: code, redirect_uri: redirect_uri, nonce: nonce)
  else
    raise StandardId::InvalidRequestError, "Google sign-in requires a code, an id_token or an access_token"
  end
end

.provider_name ⇒ Object



31
32
33
# File 'lib/standard_id/google/providers/google.rb', line 31

def provider_name
  "google"
end

.supported_authorization_params ⇒ Object



35
36
37
# File 'lib/standard_id/google/providers/google.rb', line 35

def supported_authorization_params
  [:nonce, :login_hint, :prompt, :scope, :access_type, :hd, :response_mode, :include_granted_scopes]
end

.verify_id_token(id_token:, nonce: nil) ⇒ Object

Verifies through Google's tokeninfo endpoint, which checks the signature and expiry; the audience, issuer and nonce are checked here. Needs only the client ID.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/standard_id/google/providers/google.rb', line 126

def verify_id_token(id_token:, nonce: nil)
  rescue_to_oauth_error do
    raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?

    response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
    raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)

    token_info = JSON.parse(response.body)

    unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
      raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
    end

    unless VALID_ISSUERS.include?(token_info["iss"])
      raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
    end

    # Constant-time, and the error never echoes either value.
    verify_nonce!(expected: nonce, actual: token_info["nonce"])

    {
      "sub" => token_info["sub"],
      "email" => token_info["email"],
      "email_verified" => token_info["email_verified"],
      "name" => token_info["name"],
      "given_name" => token_info["given_name"],
      "family_name" => token_info["family_name"],
      "picture" => token_info["picture"],
      "locale" => token_info["locale"]
    }.compact
  end
end