standard_id-google 0.7.0 → 0.8.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40c4313fc9b3b04605a0a36353aedfbec7cfe109f1fdd3471455c28a2c908fc8
4
- data.tar.gz: 21cf43e45c1f071ad0aec27c322c42e6ff719494da7941ee7f24b9f6a80d8cae
3
+ metadata.gz: 41656bb0ca1e58c41e45f5d1e293b9d6d172c83fa2aeb759be169c121ddb344d
4
+ data.tar.gz: fb852f913672f0ee5afd16927b5a4c5d36a4d6a46d81c39c17f89faed26edabb
5
5
  SHA512:
6
- metadata.gz: 7630091992ad0beaf567eee9e89acdcdf73625b85119a0e5ace6e209b4301f974dfe69d4efa3b679e7db7affcb93bf7028940c4f21c51e526cad8c87a58b618c
7
- data.tar.gz: 29a6a81e3224960ec686fd3e700a49ea1868e4ea19643ec5e1cfa3986c4c1b80f5e407bcdedd2bdbe8839b723f0ab5e49700fbbfb173e8d157777417bb355c1f
6
+ metadata.gz: 7d1182bfbb9b6fde3ffc787140920cef1c1fde07b4d3bf83ed9285101f061e66b3d7eb3793484b1e5e22aa932edf073412f957eb00371544146c9139876e413d
7
+ data.tar.gz: 5a60c09f63ca31495364b6d99904121e7424f3f82e49b1278f0e4640a82a41193971a2c7846f0a634d089092741d29f2db34f03697325460af1bb6c4706f673e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-26
11
+
12
+ ### Changed
13
+
14
+ - **`verify_id_token` now verifies Google ID tokens locally (default: `:jwks`).**
15
+ Signatures are checked against Google's JWKS at
16
+ `https://www.googleapis.com/oauth2/v3/certs`, using RS256 only. The JWKS
17
+ response is cached in-process (TTL 3600 s) with a kid-miss refresh subject to
18
+ a 60 s minimum interval, mirroring the sibling `standard_id-apple` provider.
19
+ The token stays within the process after the initial key fetch: no token
20
+ material is sent to Google.
21
+ Claims verified locally: algorithm (RS256 only — `alg:none` and `alg:HS256`
22
+ are rejected), expiry (with 60 s clock-skew leeway), issuer (`accounts.google.com`
23
+ or `https://accounts.google.com`), audience (configured client ID), nonce
24
+ (timing-safe), and `hd` when `google_hosted_domains` is set.
25
+ - **`jwt` gem added as a runtime dependency** (`>= 2.7, < 4`). It was already a
26
+ transitive dependency via `standard_id-apple` in most installations.
27
+
28
+ ### Deprecated
29
+
30
+ - **`config.google_id_token_verification = :tokeninfo`** (new opt-in).
31
+ The pre-0.8.0 behaviour of POSTing the raw ID token to Google's tokeninfo
32
+ endpoint is available for one release as an explicit opt-in. Set
33
+ `config.social.google_id_token_verification = :tokeninfo` to keep the old
34
+ path. **The `:tokeninfo` fallback will be removed in 0.9.0.**
35
+
36
+ ### Upgrade
37
+
38
+ - No action required for apps that do not set `google_id_token_verification`.
39
+ The JWKS path is a drop-in replacement: the same claims are returned in the
40
+ same shape.
41
+ - Apps running in network-restricted environments that block outbound HTTPS to
42
+ `googleapis.com` should either open that connection or set `:tokeninfo` while
43
+ they plan the migration.
44
+
10
45
  ## [0.7.0] - 2026-10-06
11
46
 
12
47
  ### Added
@@ -1,5 +1,7 @@
1
1
  require "json"
2
+ require "jwt"
2
3
  require "net/http"
4
+ require "openssl"
3
5
 
4
6
  module StandardId
5
7
  module Providers
@@ -12,12 +14,24 @@ module StandardId
12
14
  # developers.google.com/identity/protocols/oauth2). The legacy
13
15
  # www.googleapis.com/oauth2/v3/tokeninfo host is no longer used.
14
16
  TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
17
+ # Google's JWKS endpoint for local ID token signature verification.
18
+ JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs".freeze
15
19
  VALID_ISSUERS = ["accounts.google.com", "https://accounts.google.com"].freeze
16
20
  DEFAULT_SCOPE = "openid email profile".freeze
17
21
  AUTHORIZATION_PARAM_DEFAULTS = {
18
22
  scope: DEFAULT_SCOPE
19
23
  }.freeze
20
24
 
25
+ # Google's signing keys rotate rarely; an unknown `kid` forces an early
26
+ # refetch (see #fetch_jwk), so a long TTL costs nothing on rotation.
27
+ JWKS_CACHE_TTL = 3600
28
+ # Floor between forced refetches, so a stream of ID tokens carrying
29
+ # made-up `kid`s cannot turn this process into a request amplifier
30
+ # against Google.
31
+ JWKS_MIN_REFRESH_INTERVAL = 60
32
+ # Accepted clock drift for exp/iat, matching the sibling Apple provider.
33
+ CLOCK_SKEW_SECONDS = 60
34
+
21
35
  # Pre-0.5.0 install generators wired the fields to these variables.
22
36
  # Still read, with a deprecation warning, when the canonical variable
23
37
  # (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) is unset and the host never
@@ -27,6 +41,9 @@ module StandardId
27
41
  google_client_secret: "GOOGLE_OAUTH_CLIENT_SECRET"
28
42
  }.freeze
29
43
 
44
+ @jwks_mutex = Mutex.new
45
+ @jwks_cache = nil
46
+
30
47
  class << self
31
48
  def provider_name
32
49
  "google"
@@ -107,8 +124,8 @@ module StandardId
107
124
  # enabled provider shows the web sign-in button, and the web flow's
108
125
  # code exchange needs the secret — without it the user authenticates
109
126
  # with Google and only then does the callback fail. The native
110
- # id_token and access_token flows verify through Google's tokeninfo
111
- # endpoint and need only the client ID.
127
+ # id_token and access_token flows verify through Google's JWKS (or
128
+ # tokeninfo) and need only the client ID.
112
129
  #
113
130
  # ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and
114
131
  # GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as
@@ -121,7 +138,15 @@ module StandardId
121
138
  google_hosted_domains: { type: :any, default: -> { [] }, env: false },
122
139
  # StandardId::Google.staff_policy settings.
123
140
  google_require_for_staff: { type: :boolean, default: true },
124
- google_staff_predicate: { type: :any, default: nil, env: false }
141
+ google_staff_predicate: { type: :any, default: nil, env: false },
142
+ # ID token verification mode:
143
+ # :jwks (default) — verify the signature locally against Google's
144
+ # JWKS at #{JWKS_URI}. More reliable (no network round-trip for
145
+ # every auth) and keeps the token within the process.
146
+ # :tokeninfo — POST the raw token to Google's tokeninfo endpoint
147
+ # (legacy behaviour, pre-0.8.0). Kept for one release as an
148
+ # explicit opt-in. Will be removed in 0.9.0.
149
+ google_id_token_verification: { type: :string, default: "jwks", env: false }
125
150
  }
126
151
  end
127
152
 
@@ -176,42 +201,27 @@ module StandardId
176
201
  end
177
202
  end
178
203
 
179
- # Verifies through Google's tokeninfo endpoint, which checks the
180
- # signature and expiry; the audience, issuer and nonce are checked
181
- # here. Needs only the client ID.
204
+ # Verifies a Google ID token and returns its claims.
205
+ #
206
+ # Default mode (:jwks): verifies the signature locally against
207
+ # Google's JWKS, keeping the token within the process. Signature,
208
+ # algorithm (RS256 only), expiry, issuer and audience are all checked
209
+ # here; no network call is required after the keys are cached.
210
+ #
211
+ # Fallback mode (:tokeninfo): POSTs the raw token to Google's
212
+ # tokeninfo endpoint, which checks signature + expiry server-side;
213
+ # audience, issuer and nonce are checked here. Kept for one release as
214
+ # config.google_id_token_verification = :tokeninfo. Will be removed in
215
+ # 0.9.0.
182
216
  def verify_id_token(id_token:, nonce: nil)
183
217
  rescue_to_oauth_error do
184
218
  raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
185
219
 
186
- response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
187
- raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
188
-
189
- token_info = JSON.parse(response.body)
190
-
191
- unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
192
- raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
193
- end
194
-
195
- unless VALID_ISSUERS.include?(token_info["iss"])
196
- raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
220
+ if tokeninfo_verification?
221
+ verify_id_token_via_tokeninfo(id_token: id_token, nonce: nonce)
222
+ else
223
+ verify_id_token_via_jwks(id_token: id_token, nonce: nonce)
197
224
  end
198
-
199
- # Constant-time, and the error never echoes either value.
200
- verify_nonce!(expected: nonce, actual: token_info["nonce"])
201
-
202
- enforce_hosted_domain!(token_info) if pinned?
203
-
204
- {
205
- "hd" => token_info["hd"],
206
- "sub" => token_info["sub"],
207
- "email" => token_info["email"],
208
- "email_verified" => token_info["email_verified"],
209
- "name" => token_info["name"],
210
- "given_name" => token_info["given_name"],
211
- "family_name" => token_info["family_name"],
212
- "picture" => token_info["picture"],
213
- "locale" => token_info["locale"]
214
- }.compact
215
225
  end
216
226
  end
217
227
 
@@ -230,8 +240,150 @@ module StandardId
230
240
  end
231
241
  end
232
242
 
243
+ # Drop the in-process JWKS cache (tests, or after a known rotation).
244
+ def reset_jwks_cache!
245
+ @jwks_mutex.synchronize { @jwks_cache = nil }
246
+ end
247
+
233
248
  private
234
249
 
250
+ # True when the host has opted into the :tokeninfo fallback.
251
+ def tokeninfo_verification?
252
+ StandardId.config.google_id_token_verification.to_s == "tokeninfo"
253
+ end
254
+
255
+ # Local JWKS-based verification. RS256 only. The token never leaves
256
+ # the process after the keys are cached.
257
+ def verify_id_token_via_jwks(id_token:, nonce: nil)
258
+ _unverified_payload, header = JWT.decode(id_token, nil, false)
259
+ kid = header["kid"]
260
+ raise StandardId::InvalidRequestError, "Invalid Google ID token: header has no kid" if kid.blank?
261
+
262
+ jwk = fetch_jwk(kid: kid)
263
+ creds = credentials
264
+
265
+ # RS256 is the only algorithm Google uses for ID tokens. Enforcing
266
+ # it here blocks alg:none and alg:HS256 attacks even if the JWKS
267
+ # call is somehow poisoned.
268
+ verified_payload, = JWT.decode(
269
+ id_token,
270
+ jwk.public_key,
271
+ true,
272
+ algorithms: ["RS256"],
273
+ verify_expiration: true,
274
+ leeway: CLOCK_SKEW_SECONDS
275
+ )
276
+
277
+ # Issuer and audience are checked manually for robustness across
278
+ # JWT gem minor versions and to keep error messages consistent.
279
+ unless VALID_ISSUERS.include?(verified_payload["iss"])
280
+ raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
281
+ end
282
+
283
+ # Support the multiple-client-id case: if the config carries an
284
+ # array, any member may match.
285
+ allowed = Array(creds[:client_id])
286
+ unless allowed.include?(verified_payload["aud"])
287
+ raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
288
+ end
289
+
290
+ # Constant-time, and the error never echoes either value.
291
+ verify_nonce!(expected: nonce, actual: verified_payload["nonce"])
292
+
293
+ enforce_hosted_domain!(verified_payload) if pinned?
294
+
295
+ extract_id_token_claims(verified_payload)
296
+ rescue JWT::DecodeError => e
297
+ raise StandardId::InvalidRequestError, "Invalid Google ID token: #{e.message}"
298
+ end
299
+
300
+ # Legacy tokeninfo path. Kept as an explicit opt-in for one release.
301
+ # Deprecated: will be removed in 0.9.0.
302
+ def verify_id_token_via_tokeninfo(id_token:, nonce: nil)
303
+ response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
304
+ raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
305
+
306
+ token_info = JSON.parse(response.body)
307
+
308
+ unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
309
+ raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
310
+ end
311
+
312
+ unless VALID_ISSUERS.include?(token_info["iss"])
313
+ raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
314
+ end
315
+
316
+ # Constant-time, and the error never echoes either value.
317
+ verify_nonce!(expected: nonce, actual: token_info["nonce"])
318
+
319
+ enforce_hosted_domain!(token_info) if pinned?
320
+
321
+ extract_id_token_claims(token_info)
322
+ end
323
+
324
+ # Pull the canonical set of ID token claims from a verified payload.
325
+ def extract_id_token_claims(claims)
326
+ {
327
+ "hd" => claims["hd"],
328
+ "sub" => claims["sub"],
329
+ "email" => claims["email"],
330
+ "email_verified" => claims["email_verified"],
331
+ "name" => claims["name"],
332
+ "given_name" => claims["given_name"],
333
+ "family_name" => claims["family_name"],
334
+ "picture" => claims["picture"],
335
+ "locale" => claims["locale"]
336
+ }.compact
337
+ end
338
+
339
+ # Look `kid` up in Google's JWKS, served from an in-process cache
340
+ # (JWKS_CACHE_TTL). An unknown `kid` refetches once — Google may have
341
+ # rotated — subject to JWKS_MIN_REFRESH_INTERVAL.
342
+ def fetch_jwk(kid:)
343
+ jwk_data = find_jwk(jwks_keys, kid) || find_jwk(jwks_keys(refresh: true), kid)
344
+ raise StandardId::InvalidRequestError, "Invalid Google ID token: signing key not found in Google's JWKS" unless jwk_data
345
+
346
+ JWT::JWK.import(jwk_data)
347
+ end
348
+
349
+ def find_jwk(keys, kid)
350
+ keys.find { |key| key["kid"] == kid }
351
+ end
352
+
353
+ def jwks_keys(refresh: false)
354
+ @jwks_mutex.synchronize do
355
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
356
+ cache = @jwks_cache
357
+
358
+ stale = cache.nil? || now - cache[:fetched_at] > JWKS_CACHE_TTL
359
+ forced = refresh && cache && now - cache[:fetched_at] >= JWKS_MIN_REFRESH_INTERVAL
360
+ if stale || forced
361
+ @jwks_cache = cache = { keys: download_jwks, fetched_at: now }
362
+ end
363
+
364
+ cache[:keys]
365
+ end
366
+ end
367
+
368
+ # Fetched through StandardId::HttpClient's connection setup, so the
369
+ # request gets its timeouts and its private/internal-address guard.
370
+ # HttpClient has no public plain GET (only get_with_bearer, which would
371
+ # send an empty Authorization header), hence the two private calls.
372
+ def download_jwks
373
+ rescue_to_oauth_error("Failed to fetch Google JWKS") do
374
+ uri, resolved_ip = HttpClient.send(:validate_url!, JWKS_URI)
375
+ response = HttpClient.send(:start_connection, uri, resolved_ip: resolved_ip) do |http|
376
+ http.request(Net::HTTP::Get.new(uri))
377
+ end
378
+ raise StandardId::OAuthError, "Failed to fetch Google JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
379
+
380
+ keys = JSON.parse(response.body)["keys"]
381
+ raise StandardId::OAuthError, "Failed to fetch Google JWKS: response has no keys" unless keys.is_a?(Array)
382
+
383
+ keys
384
+ end
385
+ end
386
+
235
387
  # Pins sign-in to the configured Workspace domains, on the claims of
236
388
  # a verified ID token: `hd` must be one of them (an exact,
237
389
  # case-insensitive match; consumer accounts have no `hd`), the email
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StandardId
4
4
  module Google
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id-google
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -23,6 +23,26 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: jwt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.7'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '4'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '2.7'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '4'
26
46
  - !ruby/object:Gem::Dependency
27
47
  name: standard_id
28
48
  requirement: !ruby/object:Gem::Requirement